diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..0ee0dfacd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true + +[*.{js,jsx,ts,tsx,cjs,json,yml,yaml,css,less,scss}] +charset = utf-8 +indent_style = space +indent_size = 4 + +[CNAME] +insert_final_newline = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..94f480de9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..ac71ce785 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: wavetermdev diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 000000000..336732048 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,86 @@ +name: 🐞 Bug Report +description: Create a bug report to help us improve. +title: "[Bug]: " +labels: ["bug", "triage"] +body: + - type: markdown + attributes: + value: | + ## Bug description + - type: textarea + attributes: + label: Current Behavior + description: A concise description of what you're experiencing. + validations: + required: true + - type: textarea + attributes: + label: Expected Behavior + description: A concise description of what you expected to happen. + validations: + required: true + - type: textarea + attributes: + label: Steps To Reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. In this environment... + 2. With this config... + 3. Run '...' + 4. See error... + validations: + required: true + + - type: markdown + attributes: + value: | + ## Environment details + + We require that you provide us the version of Wave you're running so we can track issues across versions. To find the Wave version, go to the app menu (this always visible on macOS, for Windows and Linux, click the `...` button) and navigate to `Wave -> About Wave Terminal`. This will bring up the About modal. Copy the client version and paste it below. + - type: input + attributes: + label: Wave Version + description: The version of Wave you are running + placeholder: v0.8.8 + validations: + required: true + - type: dropdown + attributes: + label: Platform + description: The OS platform of the computer where you are running Wave + options: + - macOS + - Linux + - Windows + validations: + required: true + - type: input + attributes: + label: OS Version/Distribution + description: The version of the operating system of the computer where you are running Wave + placeholder: Ubuntu 24.04 + validations: + required: false + - type: dropdown + attributes: + label: Architecture + description: The architecture of the computer where you are running Wave + options: + - arm64 + - x64 + validations: + required: true + + - type: markdown + attributes: + value: | + ## Extra details + - type: textarea + attributes: + label: Anything else? + description: | + Links? References? Anything that will give us more context about the issue you are encountering! + + Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..cd5fa92c2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: true +contact_links: + - name: General Question + url: https://github.com/wavetermdev/waveterm/discussions + about: Have a question on something? Start a new discussion thread. + - name: Engage with us directly on Discord + url: https://discord.gg/XfvZ334gwU + about: Join our Discord server to get updates on new features, bug fixes, and more. + - name: Review open issues + url: https://github.com/wavetermdev/waveterm/issues + about: Please check if your issue isn't already there. diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 000000000..e7a2e3f6e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,26 @@ +name: 🚀 Feature Request / Idea +description: Suggest a new idea for this project. +title: "[Feature]: " +labels: ["enhancement", "triage"] +body: + - type: textarea + attributes: + label: Feature description + description: Describe the issue in detail and why we should add it. To help us out, please poke through our issue tracker and make sure it's not a duplicate issue. Ex. As a user, I can do [...] + validations: + required: true + - type: textarea + attributes: + label: Implementation Suggestion + description: If you have any suggestions on how to design this feature, list them here. + validations: + required: false + - type: textarea + attributes: + label: Anything else? + description: | + Links? References? Anything that will give us more context about how to deliver your feature! + + Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. + validations: + required: false diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..397ce1c97 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,46 @@ +# Wave Terminal — Copilot Instructions + +## Project Rules + +- See the overview of the project in `.kilocode/rules/overview.md` +- Read and follow all guidelines in `.kilocode/rules/rules.md` + +--- + +## Skill Guides + +This project uses a set of "skill" guides — focused how-to documents for common implementation tasks. When your task matches one of the descriptions below, **read the linked SKILL.md file before proceeding** and follow its instructions precisely. + +| Skill | File | Description | +| ------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| add-config | `.kilocode/skills/add-config/SKILL.md` | Guide for adding new configuration settings to Wave Terminal. Use when adding a new setting to the configuration system, implementing a new config key, or adding user-customizable settings. | +| add-rpc | `.kilocode/skills/add-rpc/SKILL.md` | Guide for adding new RPC calls to Wave Terminal. Use when implementing new RPC commands, adding server-client communication methods, or extending the RPC interface with new functionality. | +| add-wshcmd | `.kilocode/skills/add-wshcmd/SKILL.md` | Guide for adding new wsh commands to Wave Terminal. Use when implementing new CLI commands, adding command-line functionality, or extending the wsh command interface. | +| context-menu | `.kilocode/skills/context-menu/SKILL.md` | Guide for creating and displaying context menus in Wave Terminal. Use when implementing right-click menus, adding context menu items, creating submenus, or handling menu interactions with checkboxes and separators. | +| create-view | `.kilocode/skills/create-view/SKILL.md` | Guide for implementing a new view type in Wave Terminal. Use when creating a new view component, implementing the ViewModel interface, registering a new view type in BlockRegistry, or adding a new content type to display within blocks. | +| electron-api | `.kilocode/skills/electron-api/SKILL.md` | Guide for adding new Electron APIs to Wave Terminal. Use when implementing new frontend-to-electron communications via preload/IPC. | +| waveenv | `.kilocode/skills/waveenv/SKILL.md` | Guide for creating WaveEnv narrowings in Wave Terminal. Use when writing a named subset type of WaveEnv for a component tree, documenting environmental dependencies, or enabling mock environments for preview/test server usage. | +| wps-events | `.kilocode/skills/wps-events/SKILL.md` | Guide for working with Wave Terminal's WPS (Wave PubSub) event system. Use when implementing new event types, publishing events, subscribing to events, or adding asynchronous communication between components. | + +> **How skills work:** Each skill is a self-contained guide covering the exact files to edit, patterns to follow, and steps to take for a specific type of task in this codebase. If your task matches a skill's description, open that SKILL.md and treat it as your primary reference for the implementation. + +--- + +## Preview Server + +To run the standalone component preview (no Electron, no backend required): + +``` +task preview +``` + +This runs `cd frontend/preview && npx vite` and serves at **http://localhost:7007** (port configured in `frontend/preview/vite.config.ts`). + +To build a static preview: `task build:preview` + +**Do NOT use any of the following to start the preview — they all launch the full Electron app or serve the wrong content:** + +- `npm run dev` — runs `electron-vite dev`, launches Electron +- `npm run start` — also launches Electron +- `npx vite` from the repo root — uses the Electron-Vite config, not the preview app +- Serving the `dist/` directory — the preview app is never built there; it has its own build output diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..85c659755 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,159 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + day: "friday" + time: "09:00" + timezone: "America/Los_Angeles" + groups: + dev-dependencies-patch: + dependency-type: "development" + exclude-patterns: + - "*storybook*" + - "*electron*" + - "jotai" + - "react" + - "@types/react" + - "*react-dom" + - "*docusaurus*" + update-types: + - "patch" + dev-dependencies-minor: + dependency-type: "development" + exclude-patterns: + - "*storybook*" + - "*electron*" + - "jotai" + - "react" + - "@types/react" + - "*react-dom" + - "*docusaurus*" + update-types: + - "minor" + + prod-dependencies-patch: + dependency-type: "production" + exclude-patterns: + - "*storybook*" + - "*electron*" + - "jotai" + - "react" + - "@types/react" + - "*react-dom" + - "*docusaurus*" + update-types: + - "patch" + prod-dependencies-minor: + dependency-type: "production" + exclude-patterns: + - "*storybook*" + - "*electron*" + - "jotai" + - "react" + - "@types/react" + - "*react-dom" + - "*docusaurus*" + update-types: + - "minor" + + storybook-patch: + patterns: + - "*storybook*" + update-types: + - "patch" + storybook-minor: + patterns: + - "*storybook*" + update-types: + - "minor" + storybook-major: + patterns: + - "*storybook*" + update-types: + - "major" + + electron-patch: + patterns: + - "*electron*" + update-types: + - "patch" + electron-minor: + patterns: + - "*electron*" + update-types: + - "minor" + electron-major: + patterns: + - "*electron*" + update-types: + - "major" + + docusaurus-patch: + patterns: + - "*docusaurus*" + update-types: + - "patch" + docusaurus-minor: + patterns: + - "*docusaurus*" + update-types: + - "minor" + docusaurus-major: + patterns: + - "*docusaurus*" + update-types: + - "major" + + react-patch: + patterns: + - "react" + - "@types/react" + - "*react-dom" + update-types: + - "patch" + react-minor: + patterns: + - "react" + - "@types/react" + - "*react-dom" + update-types: + - "minor" + react-major: + patterns: + - "react" + - "@types/react" + - "*react-dom" + update-types: + - "major" + + jotai-patch: + patterns: + - "jotai" + update-types: + - "patch" + jotai-minor: + patterns: + - "jotai" + update-types: + - "minor" + jotai-major: + patterns: + - "jotai" + update-types: + - "major" + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + day: "friday" + time: "09:00" + timezone: "America/Los_Angeles" + - package-ecosystem: "github-actions" + directory: "/.github/workflows" + schedule: + interval: "weekly" + day: "friday" + time: "09:00" + timezone: "America/Los_Angeles" diff --git a/.github/workflows/agent-tests.yml b/.github/workflows/agent-tests.yml new file mode 100644 index 000000000..a76d3beaf --- /dev/null +++ b/.github/workflows/agent-tests.yml @@ -0,0 +1,41 @@ +name: Agent Tests + +# The agent runtime moved from Go (pkg/agent + pkg/aiusechat, deleted in +# 2994635c) to TypeScript in the Electron main process (emain/agent + +# emain/aiconfig), so this job now runs the vitest suite that covers it. + +on: + pull_request: + paths: + - "emain/agent/**" + - "emain/aiconfig/**" + - "emain/ai/**" + - "frontend/app/store/**" + - "frontend/app/term/**" + - "package.json" + - "package-lock.json" + push: + branches: + - main + - "feat/**" + +env: + NODE_VERSION: 22 + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: package-lock.json + + - name: npm ci + run: npm ci --no-audit --no-fund + + - name: Run vitest + run: npx vitest run diff --git a/.github/workflows/build-helper.yml b/.github/workflows/build-helper.yml new file mode 100644 index 000000000..8fe7118ae --- /dev/null +++ b/.github/workflows/build-helper.yml @@ -0,0 +1,209 @@ +# Build Helper workflow - Builds, signs, and packages binaries for each supported platform, then uploads to a staging bucket in S3 for wider distribution. +# For more information on the macOS signing and notarization, see https://www.electron.build/code-signing and https://www.electron.build/configuration/mac +# For more information on the Windows Code Signing, see https://docs.digicert.com/en/digicert-keylocker/ci-cd-integrations/plugins/github-custom-action-for-keypair-signing.html and https://docs.digicert.com/en/digicert-keylocker/signing-tools/sign-authenticode-with-electron-builder-using-ksp-integration.html + +name: Build Helper +run-name: Build ${{ github.ref_name }}${{ github.event_name == 'workflow_dispatch' && ' - Manual' || '' }} +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+*" + workflow_dispatch: +env: + GO_VERSION: "1.25.6" + NODE_VERSION: 22 + NODE_OPTIONS: --max-old-space-size=4096 +jobs: + build-app: + outputs: + version: ${{ steps.set-version.outputs.WAVETERM_VERSION }} + strategy: + matrix: + include: + - platform: "darwin" + runner: "macos-latest" + - platform: "linux" + runner: "ubuntu-latest" + - platform: "linux" + runner: ubuntu-24.04-arm + - platform: "windows" + runner: "windows-latest" + # - platform: "windows" + # runner: "windows-11-arm64-16core" + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v6 + - name: Install Linux Build Dependencies (Linux only) + if: matrix.platform == 'linux' + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends -y libarchive-tools libopenjp2-tools rpm squashfs-tools + sudo snap install snapcraft --classic + sudo snap install lxd + sudo lxd init --auto + sudo snap refresh + - name: Install Zig (not Mac) + if: matrix.platform != 'darwin' + uses: mlugg/setup-zig@v2 + + # The pre-installed version of the AWS CLI has a segfault problem so we'll install it via Homebrew instead. + - name: Upgrade AWS CLI (Mac only) + if: matrix.platform == 'darwin' + run: brew install awscli + + # The version of FPM that comes bundled with electron-builder doesn't include a Linux ARM target. Installing Gems onto the runner is super quick so we'll just do this for all targets. + - name: Install FPM (not Windows) + if: matrix.platform != 'windows' + run: sudo gem install fpm + - name: Install FPM (Windows only) + if: matrix.platform == 'windows' + run: gem install fpm + + # General build dependencies + - uses: actions/setup-go@v6 + with: + go-version: ${{env.GO_VERSION}} + cache-dependency-path: | + go.sum + - uses: actions/setup-node@v6 + with: + node-version: ${{env.NODE_VERSION}} + cache: npm + cache-dependency-path: package-lock.json + - name: Force git deps to HTTPS + run: | + git config --global url.https://github.com/.insteadof ssh://git@github.com/ + git config --global url.https://github.com/.insteadof git@github.com: + - uses: nick-fields/retry@v4 + name: npm ci + with: + command: npm ci --no-audit --no-fund + retry_on: error + max_attempts: 3 + timeout_minutes: 5 + env: + GIT_ASKPASS: "echo" + GIT_TERMINAL_PROMPT: "0" + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: "Set Version" + id: set-version + run: echo "WAVETERM_VERSION=$(task version)" >> "$GITHUB_OUTPUT" + shell: bash + + # Windows Code Signing Setup + - name: Set up certificate (Windows only) + if: matrix.platform == 'windows' && github.event_name != 'workflow_dispatch' + run: | + echo "${{ secrets.SM_CLIENT_CERT_FILE_B64 }}" | base64 --decode > /d/Certificate_pkcs12.p12 + shell: bash + - name: Set signing variables (Windows only) + if: matrix.platform == 'windows' && github.event_name != 'workflow_dispatch' + id: variables + run: | + echo "SM_HOST=${{ secrets.SM_HOST }}" >> "$GITHUB_ENV" + echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> "$GITHUB_ENV" + echo "SM_CODE_SIGNING_CERT_SHA1_HASH=${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}" >> "$GITHUB_ENV" + echo "SM_CLIENT_CERT_FILE=D:\\Certificate_pkcs12.p12" >> "$GITHUB_ENV" + echo "SM_CLIENT_CERT_FILE=D:\\Certificate_pkcs12.p12" >> "$GITHUB_OUTPUT" + echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> "$GITHUB_ENV" + echo "C:\Program Files (x86)\Windows Kits\10\App Certification Kit" >> $GITHUB_PATH + echo "C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools" >> $GITHUB_PATH + echo "C:\Program Files\DigiCert\DigiCert Keylocker Tools" >> $GITHUB_PATH + shell: bash + - name: Setup Keylocker KSP (Windows only) + if: matrix.platform == 'windows' && github.event_name != 'workflow_dispatch' + run: | + curl -X GET https://one.digicert.com/signingmanager/api-ui/v1/releases/Keylockertools-windows-x64.msi/download -H "x-api-key:%SM_API_KEY%" -o Keylockertools-windows-x64.msi + msiexec /i Keylockertools-windows-x64.msi /quiet /qn + C:\Windows\System32\certutil.exe -csp "DigiCert Signing Manager KSP" -key -user + smctl windows certsync + shell: cmd + + # Build and upload packages + - name: Build (Linux) + if: matrix.platform == 'linux' + run: task package + env: + USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one. + SNAPCRAFT_BUILD_ENVIRONMENT: host + # Retry Darwin build in case of notarization failures + - uses: nick-fields/retry@v4 + name: Build (Darwin) + if: matrix.platform == 'darwin' + with: + command: task package + timeout_minutes: 120 + retry_on: error + max_attempts: 3 + env: + USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one. + CSC_LINK: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_CERTIFICATE_2}} + CSC_KEY_PASSWORD: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_CERTIFICATE_PWD_2 }} + APPLE_ID: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_NOTARIZATION_APPLE_ID_2 }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_NOTARIZATION_PWD_2 }} + APPLE_TEAM_ID: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_NOTARIZATION_TEAM_ID_2 }} + STATIC_DOCSITE_PATH: ${{env.STATIC_DOCSITE_PATH}} + - name: Build (Windows) + if: matrix.platform == 'windows' + run: task package + env: + USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one. + CSC_LINK: ${{ steps.variables.outputs.SM_CLIENT_CERT_FILE }} + CSC_KEY_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }} + STATIC_DOCSITE_PATH: ${{env.STATIC_DOCSITE_PATH}} + shell: powershell # electron-builder's Windows code signing package has some compatibility issues with pwsh, so we need to use Windows Powershell + + # Upload artifacts to the S3 staging and to the workflow output for the draft release job + - name: Upload to S3 staging + if: github.event_name != 'workflow_dispatch' + run: task artifacts:upload + env: + AWS_ACCESS_KEY_ID: "${{ secrets.ARTIFACTS_KEY_ID }}" + AWS_SECRET_ACCESS_KEY: "${{ secrets.ARTIFACTS_KEY_SECRET }}" + AWS_DEFAULT_REGION: us-west-2 + - name: Upload artifacts + uses: actions/upload-artifact@v7 + with: + name: ${{ matrix.runner }} + path: make + - name: Upload Snapcraft logs on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: ${{ matrix.runner }}-log + path: /home/runner/.local/state/snapcraft/log + create-release: + runs-on: ubuntu-latest + needs: build-app + permissions: + contents: write + if: ${{ github.event_name != 'workflow_dispatch' }} + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + path: make + merge-multiple: true + - name: Create draft release + uses: softprops/action-gh-release@v2 + with: + prerelease: ${{ contains(github.ref_name, '-beta') }} + name: Wave Terminal ${{ github.ref_name }} Release + generate_release_notes: true + draft: true + files: | + make/*.zip + make/*.dmg + make/*.exe + make/*.msi + make/*.rpm + make/*.deb + make/*.pacman + make/*.snap + make/*.flatpak + make/*.AppImage diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml new file mode 100644 index 000000000..fa7f31df6 --- /dev/null +++ b/.github/workflows/bump-version.yml @@ -0,0 +1,89 @@ +# Workflow to manage bumping the package version and pushing it to the target branch with a new tag. +# This workflow uses a GitHub App to bypass branch protection and uses the GitHub API directly to ensure commits and tags are signed. +# For more information, see this doc: https://github.com/Nautilus-Cyberneering/pygithub/blob/main/docs/how_to_sign_automatic_commits_in_github_actions.md + +name: Bump Version +run-name: "branch: ${{ github.ref_name }}; semver-bump: ${{ inputs.bump }}; prerelease: ${{ inputs.is-prerelease }}" +on: + workflow_dispatch: + inputs: + bump: + description: SemVer Bump + required: true + type: choice + default: none + options: + - none + - patch + - minor + - major + is-prerelease: + description: Is Prerelease + required: true + type: boolean + default: true +env: + NODE_VERSION: 22 +jobs: + bump-version: + runs-on: ubuntu-latest + steps: + - name: Get App Token + uses: actions/create-github-app-token@v3 + id: app-token + with: + app-id: ${{ vars.WAVE_BUILDER_APPID }} + private-key: ${{ secrets.WAVE_BUILDER_KEY }} + - uses: actions/checkout@v6 + with: + token: ${{ steps.app-token.outputs.token }} + + # General build dependencies + - uses: actions/setup-node@v6 + with: + node-version: ${{env.NODE_VERSION}} + cache: npm + cache-dependency-path: package-lock.json + - uses: nick-fields/retry@v4 + name: npm ci + with: + command: npm ci --no-audit --no-fund + retry_on: error + max_attempts: 3 + timeout_minutes: 5 + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: "Bump Version: ${{ inputs.bump }}" + id: bump-version + run: echo "WAVETERM_VERSION=$( task version -- ${{ inputs.bump }} ${{inputs.is-prerelease}} )" >> "$GITHUB_OUTPUT" + shell: bash + + - name: "Push version bump: ${{ steps.bump-version.outputs.WAVETERM_VERSION }}" + if: github.ref_protected + run: | + # Create a new commit for the package version bump in package.json + export VERSION=${{ steps.bump-version.outputs.WAVETERM_VERSION }} + export MESSAGE="chore: bump package version to $VERSION" + export FILE=package.json + export BRANCH=${{github.ref_name}} + export SHA=$( git rev-parse $BRANCH:$FILE ) + export CONTENT=$( base64 -i $FILE ) + gh api --method PUT /repos/:owner/:repo/contents/$FILE \ + --field branch="$BRANCH" \ + --field message="$MESSAGE" \ + --field content="$CONTENT" \ + --field sha="$SHA" + + # Fetch the new commit and create a tag referencing it + git fetch + export TAG_SHA=$( git rev-parse origin/$BRANCH ) + gh api --method POST /repos/:owner/:repo/git/refs \ + --field ref="refs/tags/v$VERSION" \ + --field sha="$TAG_SHA" + shell: bash + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..30a8979b9 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,137 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: ["main"] + paths: + - "**/*.go" + - "**/*.ts" + - "**/*.tsx" + pull_request: + branches: ["main"] + paths: + - "**/*.go" + - "**/*.ts" + - "**/*.tsx" + types: + - opened + - synchronize + - reopened + - ready_for_review + schedule: + - cron: "36 5 * * 5" + +env: + NODE_VERSION: 22 + GO_VERSION: "1.25.6" + +jobs: + analyze: + name: Analyze + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners + # Consider using larger runners for possible analysis time improvements. + if: github.event.pull_request.draft == false + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: ["go", "javascript-typescript"] + # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ] + # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/setup-node@v6 + with: + node-version: ${{env.NODE_VERSION}} + cache: npm + cache-dependency-path: package-lock.json + - uses: nick-fields/retry@v4 + name: npm ci + with: + command: npm ci --no-audit --no-fund + retry_on: error + max_attempts: 3 + timeout_minutes: 5 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version: ${{env.GO_VERSION}} + cache-dependency-path: | + go.sum + # We use Zig instead of glibc for cgo compilation as it is more-easily statically linked + - name: Setup Zig + run: sudo snap install zig --classic --beta + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + - name: Generate bindings + run: task generate + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild (not Go) + if: matrix.language != 'go' + uses: github/codeql-action/autobuild@v4 + + - name: Build (Go only) + if: matrix.language == 'go' + run: | + task build:server + task build:wsh + + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 000000000..20f05975b --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,67 @@ +name: Copilot Setup Steps + +on: + workflow_dispatch: + push: + paths: [.github/workflows/copilot-setup-steps.yml] + pull_request: + paths: [.github/workflows/copilot-setup-steps.yml] + +# Note: global env vars are NOT used here — they are not reliable in all +# GitHub Actions contexts (e.g. Copilot setup steps). Values are inlined +# directly into each step that needs them. + +jobs: + copilot-setup-steps: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v6 + + # Go + Node versions match your helper + - uses: actions/setup-go@v6 + with: + go-version: "1.25.6" + cache-dependency-path: go.sum + + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + cache-dependency-path: package-lock.json + + # Zig is used by your Linux CGO builds (kept available, but we won't build here) + - uses: mlugg/setup-zig@v2 + + # Task CLI for your Taskfile + - uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + # Git HTTPS so deps resolve non-interactively + - name: Force git deps to HTTPS + run: | + git config --global url.https://github.com/.insteadof ssh://git@github.com/ + git config --global url.https://github.com/.insteadof git@github.com: + + # Warm caches only (no builds) + - uses: nick-fields/retry@v4 + name: npm ci + with: + command: npm ci --no-audit --no-fund + retry_on: error + max_attempts: 3 + timeout_minutes: 5 + env: + GIT_ASKPASS: "echo" + GIT_TERMINAL_PROMPT: "0" + + - name: Pre-fetch Go modules + env: + GOTOOLCHAIN: auto + run: | + go version + go mod download diff --git a/.github/workflows/merge-gatekeeper.yml b/.github/workflows/merge-gatekeeper.yml new file mode 100644 index 000000000..fba485722 --- /dev/null +++ b/.github/workflows/merge-gatekeeper.yml @@ -0,0 +1,32 @@ +--- +name: Merge Gatekeeper + +on: + pull_request_target: + branches: + - main + - master + types: + - opened + - synchronize + - reopened + - ready_for_review + +jobs: + merge-gatekeeper: + runs-on: ubuntu-latest + if: github.event.pull_request.draft == false + # Restrict permissions of the GITHUB_TOKEN. + # Docs: https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs + permissions: + checks: read + statuses: read + steps: + - name: Run Merge Gatekeeper + # NOTE: v1 is updated to reflect the latest v1.x.y. Please use any tag/branch that suits your needs: + # https://github.com/upsidr/merge-gatekeeper/tags + # https://github.com/upsidr/merge-gatekeeper/branches + uses: upsidr/merge-gatekeeper@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + ignored: Analyze (go), Analyze (javascript-typescript), License Compliance, CodeRabbit diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 000000000..268e37724 --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,96 @@ +# Workflow to copy artifacts from the staging bucket to the release bucket when a new GitHub Release is published. + +name: Publish Release +run-name: Publish ${{ github.ref_name }} +on: + release: + types: [published] +jobs: + publish-s3: + name: Publish to Releases + if: ${{ startsWith(github.ref, 'refs/tags/') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Publish from staging + run: "task artifacts:publish:${{ github.ref_name }}" + env: + AWS_ACCESS_KEY_ID: "${{ secrets.PUBLISHER_KEY_ID }}" + AWS_SECRET_ACCESS_KEY: "${{ secrets.PUBLISHER_KEY_SECRET }}" + AWS_DEFAULT_REGION: us-west-2 + shell: bash + publish-snap-amd64: + name: Publish AMD64 Snap + if: ${{ startsWith(github.ref, 'refs/tags/') }} + needs: [publish-s3] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Install Snapcraft + run: sudo snap install snapcraft --classic + shell: bash + - name: Download Snap from Release + uses: robinraju/release-downloader@v1 + with: + tag: ${{github.ref_name}} + fileName: "*amd64.snap" + - name: Publish to Snapcraft + run: "task artifacts:snap:publish:${{ github.ref_name }}" + env: + SNAPCRAFT_STORE_CREDENTIALS: "${{secrets.SNAPCRAFT_LOGIN_CREDS}}" + shell: bash + publish-snap-arm64: + name: Publish ARM64 Snap + if: ${{ startsWith(github.ref, 'refs/tags/') }} + needs: [publish-s3] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Install Snapcraft + run: sudo snap install snapcraft --classic + shell: bash + - name: Download Snap from Release + uses: robinraju/release-downloader@v1 + with: + tag: ${{github.ref_name}} + fileName: "*arm64.snap" + - name: Publish to Snapcraft + run: "task artifacts:snap:publish:${{ github.ref_name }}" + env: + SNAPCRAFT_STORE_CREDENTIALS: "${{secrets.SNAPCRAFT_LOGIN_CREDS}}" + shell: bash + bump-winget: + name: Submit WinGet PR + if: ${{ startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, 'beta') }} + needs: [publish-s3] + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Install wingetcreate + run: winget install -e --silent --accept-package-agreements --accept-source-agreements wingetcreate + shell: pwsh + - name: Submit WinGet version bump + run: "task artifacts:winget:publish:${{ github.ref_name }}" + env: + GITHUB_TOKEN: ${{ secrets.WINGET_BUMP_PAT }} + shell: pwsh diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..4df72ab3b --- /dev/null +++ b/.gitignore @@ -0,0 +1,66 @@ +.task +frontend/dist +dist/ +dist-dev/ +frontend/node_modules +node_modules/ +frontend/bindings +bindings/ +*.log +*.tsbuildinfo +bin/ +*.dmg +*.exe +.DS_Store +*~ +out/ +make/ +artifacts/ +mikework/ +aiplans/ +manifests/ +.env +out + +# Yarn Modern +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + + +*storybook.log +storybook-static/ + +test-results.xml + +docsite/ + +.kilo-format-temp-* +.superpowers +docs/superpowers +.claude +ROADMAP.md +ROADMAP-v2.md + +# Agent run-time traces — local diagnostic dumps, not source +.crest-trajectories/ + +# TB2 benchmark results +jobs/ +__pycache__/ +.env +public/nld-model/ +training/.google-10k-cache.txt +frontend/app/term/nld/word-lists/.google-10k-cache.txt + +# NLD training artifacts (not source) +training/.venv/ +training/onnx_model/ +training/finetuned_model/ +training/optimum_tmp/ +training/eval_results.json +training/augmented_model.onnx diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 000000000..e8bd47262 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,11 @@ +version: 2 + +linters: + disable: + - unused + +issues: + exclude-rules: + - linters: + - unused + text: "unused parameter" diff --git a/.kilocode/rules/overview.md b/.kilocode/rules/overview.md new file mode 100644 index 000000000..944a4021d --- /dev/null +++ b/.kilocode/rules/overview.md @@ -0,0 +1,154 @@ +# Wave Terminal - High Level Architecture Overview + +## Project Description + +Wave Terminal is an open-source AI-native terminal built for seamless workflows. It's an Electron application that serves as a command line terminal host (it hosts CLI applications rather than running inside a CLI). The application combines a React frontend with a Go backend server to provide a modern terminal experience with advanced features. + +## Top-Level Directory Structure + +``` +waveterm/ +├── emain/ # Electron main process code +├── frontend/ # React application (renderer process) +├── cmd/ # Go command-line applications +├── pkg/ # Go packages/modules +├── db/ # Database migrations +├── docs/ # Documentation (Docusaurus) +├── build/ # Build configuration and assets +├── assets/ # Application assets (icons, images) +├── public/ # Static public assets +├── tests/ # Test files +├── .github/ # GitHub workflows and configuration +└── Configuration files (package.json, tsconfig.json, etc.) +``` + +## Architecture Components + +### 1. Electron Main Process (`emain/`) + +The Electron main process handles the native desktop application layer: + +**Key Files:** + +- [`emain.ts`](emain/emain.ts) - Main entry point, application lifecycle management +- [`emain-window.ts`](emain/emain-window.ts) - Window management (`WaveBrowserWindow` class) +- [`emain-tabview.ts`](emain/emain-tabview.ts) - Tab view management (`WaveTabView` class) +- [`emain-wavesrv.ts`](emain/emain-wavesrv.ts) - Go backend server integration +- [`emain-wsh.ts`](emain/emain-wsh.ts) - WSH (Wave Shell) client integration +- [`emain-ipc.ts`](emain/emain-ipc.ts) - IPC handlers for frontend ↔ main process communication +- [`emain-menu.ts`](emain/emain-menu.ts) - Application menu system +- [`updater.ts`](emain/updater.ts) - Auto-update functionality +- [`preload.ts`](emain/preload.ts) - Preload script for renderer security +- [`preload-webview.ts`](emain/preload-webview.ts) - Webview preload script + +### 2. Frontend React Application (`frontend/`) + +The React application runs in the Electron renderer process: + +**Structure:** + +``` +frontend/ +├── app/ # Main application code +│ ├── app.tsx # Root App component +│ ├── aipanel/ # AI panel UI +│ ├── block/ # Block-based UI components +│ ├── element/ # Reusable UI elements +│ ├── hook/ # Custom React hooks +│ ├── modals/ # Modal components +│ ├── store/ # State management (Jotai) +│ ├── tab/ # Tab components +│ ├── view/ # Different view types +│ │ ├── codeeditor/ # Code editor (Monaco) +│ │ ├── preview/ # File preview +│ │ ├── sysinfo/ # System info view +│ │ ├── term/ # Terminal view +│ │ ├── tsunami/ # Tsunami builder view +│ │ ├── vdom/ # Virtual DOM view +│ │ ├── waveai/ # AI chat integration +│ │ ├── waveconfig/ # Config editor view +│ │ └── webview/ # Web view +│ └── workspace/ # Workspace management +├── builder/ # Builder app entry +├── layout/ # Layout system +├── preview/ # Standalone preview renderer +├── types/ # TypeScript type definitions +└── util/ # Utility functions +``` + +**Key Technologies:** + +- Electron (desktop application shell) +- React 19 with TypeScript +- Jotai for state management +- Monaco Editor for code editing +- XTerm.js for terminal emulation +- Tailwind CSS v4 for styling +- SCSS for additional styling (deprecated, new components should use Tailwind) +- Vite / electron-vite for bundling +- Task (Taskfile.yml) for build and code generation commands + +### 3. Go Backend Server (`cmd/server/`) + +The Go backend server handles all heavy lifting operations: + +**Entry Point:** [`main-server.go`](cmd/server/main-server.go) + +### 4. Go Packages (`pkg/`) + +The Go codebase is organized into modular packages: + +**Key Packages:** + +- `wstore/` - Database and storage layer +- `wconfig/` - Configuration management +- `wcore/` - Core business logic +- `wshrpc/` - RPC communication system +- `wshutil/` - WSH (Wave Shell) utilities +- `blockcontroller/` - Block execution management +- `remote/` - Remote connection handling +- `filestore/` - File storage system +- `web/` - Web server and WebSocket handling +- `telemetry/` - Usage analytics and telemetry +- `waveobj/` - Core data objects +- `service/` - Service layer +- `wps/` - Wave PubSub event system +- `waveai/` - AI functionality +- `shellexec/` - Shell execution +- `util/` - Common utilities + +### 5. Command Line Tools (`cmd/`) + +Key Go command-line utilities: + +- `wsh/` - Wave Shell command-line tool +- `server/` - Main backend server +- `generatego/` - Code generation +- `generateschema/` - Schema generation +- `generatets/` - TypeScript generation + +## Communication Architecture + +The core communication system is built around the **WSH RPC (Wave Shell RPC)** system, which provides a unified interface for all inter-process communication: frontend ↔ Go backend, Electron main process ↔ backend, and backend ↔ remote systems (SSH, WSL). + +### WSH RPC System (`pkg/wshrpc/`) + +The WSH RPC system is the backbone of Wave Terminal's communication architecture: + +**Key Components:** + +- [`wshrpctypes.go`](pkg/wshrpc/wshrpctypes.go) - Core RPC interface and type definitions (source of truth for all RPC commands) +- [`wshserver/`](pkg/wshrpc/wshserver/) - Server-side RPC implementation +- [`wshremote/`](pkg/wshrpc/wshremote/) - Remote connection handling +- [`wshclient.go`](pkg/wshrpc/wshclient.go) - Go client for making RPC calls +- [`frontend/app/store/wshclientapi.ts`](frontend/app/store/wshclientapi.ts) - Generated TypeScript RPC client + +**Routing:** Callers address RPC calls using _routes_ (e.g. a block ID, connection name, or `"waveapp"`) rather than caring about the underlying transport. The RPC layer resolves the route to the correct transport (WebSocket, Unix socket, SSH tunnel, stdio) automatically. This means the same RPC interface works whether the target is local or a remote SSH connection. + +## Development Notes + +- **Build commands** - Use `task` (Taskfile.yml) for all build, generate, and packaging commands +- **Code generation** - Run `task generate` after modifying Go types in `pkg/wshrpc/wshrpctypes.go`, `pkg/wconfig/settingsconfig.go`, or `pkg/waveobj/wtypemeta.go` +- **Testing** - Vitest for frontend unit tests; standard `go test` for Go packages +- **Database migrations** - SQL migration files in `db/migrations-wstore/` and `db/migrations-filestore/` +- **Documentation** - Docusaurus site in `docs/` diff --git a/.kilocode/rules/rules.md b/.kilocode/rules/rules.md new file mode 100644 index 000000000..904292ea9 --- /dev/null +++ b/.kilocode/rules/rules.md @@ -0,0 +1,204 @@ +Wave Terminal is a modern terminal which provides graphical blocks, dynamic layout, workspaces, and SSH connection management. It is cross platform and built on electron. + +### Project Structure + +It has a TypeScript/React frontend and a Go backend. They talk together over `wshrpc` a custom RPC protocol that is implemented over websocket (and domain sockets). + +### Coding Guidelines + +- **Go Conventions**: + - Don't use custom enum types in Go. Instead, use string constants (e.g., `const StatusRunning = "running"` rather than creating a custom type like `type Status string`). + - Use string constants for status values, packet types, and other string-based enumerations. + - in Go code, prefer using Printf() vs Println() + - use "Make" as opposed to "New" for struct initialization func names + - in general const decls go at the top of the file (before types and functions) + - NEVER run `go build` (especially in weird sub-package directories). we can tell if everything compiles by seeing there are no problems/errors. +- **Synchronization**: + - Always prefer to use the `lock.Lock(); defer lock.Unlock()` pattern for synchronization if possible + - Avoid inline lock/unlock pairs - instead create helper functions that use the defer pattern + - When accessing shared data structures (maps, slices, etc.), ensure proper locking + - Example: Instead of `gc.lock.Lock(); gc.map[key]++; gc.lock.Unlock()`, create a helper function like `getNextValue(key string) int { gc.lock.Lock(); defer gc.lock.Unlock(); gc.map[key]++; return gc.map[key] }` +- **TypeScript Imports**: + - Use `@/...` for imports from different parts of the project (configured in `tsconfig.json` as `"@/*": ["frontend/*"]`). + - Prefer relative imports (`"./name"`) only within the same directory. + - Use named exports exclusively; avoid default exports. It's acceptable to export functions directly (e.g., React Components). + - Our indent is 4 spaces +- **JSON Field Naming**: All fields must be lowercase, without underscores. +- **TypeScript Conventions** + - **Type Handling**: + - In TypeScript we have strict null checks off, so no need to add "| null" to all the types. + - In TypeScript for Jotai atoms, if we want to write, we need to type the atom as a PrimitiveAtom + - Jotai has a bug with strict null checks off where if you create a null atom, e.g. atom(null) it does not "type" correctly. That's no issue, just cast it to the proper PrimitiveAtom type (no "| null") and it will work fine. + - Generally never use "=== undefined" or "!== undefined". This is bad style. Just use a "== null" or "!= null" unless it is a very specific case where we need to distinguish undefined from null. + - **Coding Style**: + - Use all lowercase filenames (except where case is actually important like Taskfile.yml) + - Import the "cn" function from "@/util/util" to do classname / clsx class merge (it uses twMerge underneath) + - Do NOT create private fields in classes (they are impossible to inspect) + - Use PascalCase for global consts at the top of files + - **Component Practices**: + - Make sure to add cursor-pointer to buttons/links and clickable items + - NEVER use cursor-help (it looks terrible) + - useAtom() and useAtomValue() are react HOOKS, so they must be called at the component level not inline in JSX + - If you use React.memo(), make sure to add a displayName for the component + - Other + - never use atob() or btoa() (not UTF-8 safe). use functions in frontend/util/util.ts for base64 decoding and encoding +- In general, when writing functions, we prefer _early returns_ rather than putting the majority of a function inside of an if block. + +### Styling + +- We use **Tailwind v4** to style. Custom stuff is defined in frontend/tailwindsetup.css +- _never_ use cursor-help, or cursor-not-allowed (it looks terrible) +- We have custom CSS setup as well, so it is a hybrid system. For new code we prefer tailwind, and are working to migrate code to all use tailwind. +- For accent buttons, use "bg-accent/80 text-primary rounded hover:bg-accent transition-colors cursor-pointer" (if you do "bg-accent hover:bg-accent/80" it looks weird as on hover the button gets darker instead of lighter) + +### RPC System + +To define a new RPC call, add the new definition to `pkg/wshrpc/wshrpctypes.go` including any input/output data that is required. After modifying wshrpctypes.go run `task generate` to generate the client APIs. + +For normal "server" RPCs (where a frontend client is calling the main server) you should implement the RPC call in `pkg/wshrpc/wshserver.go`. + +### Electron API + +From within the FE to get the electron API (e.g. the preload functions): + +```ts +import { getApi } from "@/store/global"; + +getApi().getIsDev(); +``` + +The full API is defined in custom.d.ts as type ElectronApi. + +### Code Generation + +- **TypeScript Types**: TypeScript types are automatically generated from Go types. After modifying Go types in `pkg/wshrpc/wshrpctypes.go`, run `task generate` to update the TypeScript type definitions in `frontend/types/gotypes.d.ts`. +- **Manual Edits**: Do not manually edit generated files like `frontend/types/gotypes.d.ts` or `frontend/app/store/wshclientapi.ts`. Instead, modify the source Go types and run `task generate`. + +### Frontend Architecture + +- The application uses Jotai for state management. +- When working with Jotai atoms that need to be updated, define them as `PrimitiveAtom` rather than just `atom`. + +### Notes + +- **CRITICAL: Completion format MUST be: "Done: [one-line description]"** +- **Keep your Task Completed summaries VERY short** +- **No lengthy pre-completion summaries** - Do not provide detailed explanations of implementation before using attempt_completion +- **No recaps of changes** - Skip explaining what was done before completion +- **Go directly to completion** - After making changes, proceed directly to attempt_completion without summarizing +- The project is currently an un-released POC / MVP. Do not worry about backward compatibility when making changes +- With React hooks, always complete all hook calls at the top level before any conditional returns (including jotai hook calls useAtom and useAtomValue); when a user explicitly tells you a function handles null inputs, trust them and stop trying to "protect" it with unnecessary checks or workarounds. +- **Match response length to question complexity** - For simple, direct questions in Ask mode (especially those that can be answered in 1-2 sentences), provide equally brief answers. Save detailed explanations for complex topics or when explicitly requested. +- **CRITICAL** - useAtomValue and useAtom are React HOOKS. They cannot be used inline in JSX code, they must appear at the top of a component in the hooks area of the react code. +- for simple functions, we prefer `if (!cond) { return }; functionality;` pattern over `if (cond) { functionality }` because it produces less indentation and is easier to follow. +- It is now 2026, so if you write new files, or update files use 2026 for the copyright year +- React.MutableRefObject is deprecated, just use React.RefObject now (in React 19 RefObject is always mutable) + +### Strict Comment Rules + +- **NEVER add comments that merely describe what code is doing**: + - ❌ `mutex.Lock() // Lock the mutex` + - ❌ `counter++ // Increment the counter` + - ❌ `buffer.Write(data) // Write data to buffer` + - ❌ `// Header component for app run list` (above AppRunListHeader) + - ❌ `// Updated function to include onClick parameter` + - ❌ `// Changed padding calculation` + - ❌ `// Removed unnecessary div` + - ❌ `// Using the model's width value here` +- **Only use comments for**: + - Explaining WHY a particular approach was chosen + - Documenting non-obvious edge cases or side effects + - Warning about potential pitfalls in usage + - Explaining complex algorithms that can't be simplified +- **When in doubt, leave it out**. No comment is better than a redundant comment. +- **Never add comments explaining code changes** - The code should speak for itself, and version control tracks changes. The one exception to this rule is if it is a very unobvious implementation. Something that someone would typically implement in a different (wrong) way. Then the comment helps us remember WHY we changed it to a less obvious implementation. +- **Never remove existing comments** unless specifically directed by the user. Comments that are already defined in existing code have been vetted by the user. + +### Jotai Model Pattern (our rules) + +- **Atoms live on the model.** +- **Simple atoms:** define as **field initializers**. +- **Atoms that depend on values/other atoms:** create in the **constructor**. +- Models **never use React hooks**; they use `globalStore.get/set`. +- It's fine to call model methods from **event handlers** or **`useEffect`**. +- Models use the **singleton pattern** with a `private static instance` field, a `private constructor`, and a `static getInstance()` method. +- The constructor is `private`; callers always use `getInstance()`. + +```ts +// model/MyModel.ts +import * as jotai from "jotai"; +import { globalStore } from "@/app/store/jotaiStore"; + +export class MyModel { + private static instance: MyModel | null = null; + + // simple atoms (field init) + statusAtom = jotai.atom<"idle" | "running" | "error">("idle"); + outputAtom = jotai.atom(""); + + // ctor-built atoms (need types) + lengthAtom!: jotai.Atom; + thresholdedAtom!: jotai.Atom; + + private constructor(initialThreshold = 20) { + this.lengthAtom = jotai.atom((get) => get(this.outputAtom).length); + this.thresholdedAtom = jotai.atom((get) => get(this.lengthAtom) > initialThreshold); + } + + static getInstance(): MyModel { + if (!MyModel.instance) { + MyModel.instance = new MyModel(); + } + return MyModel.instance; + } + + static resetInstance(): void { + MyModel.instance = null; + } + + async doWork() { + globalStore.set(this.statusAtom, "running"); + // ... do work ... + globalStore.set(this.statusAtom, "idle"); + } +} +``` + +```tsx +// component usage (events & effects OK) +import { useAtomValue } from "jotai"; + +function Panel() { + const model = MyModel.getInstance(); + const status = useAtomValue(model.statusAtom); + const isBig = useAtomValue(model.thresholdedAtom); + + const onClick = () => model.doWork(); + + return ( +
+ {status} â€ĸ {String(isBig)} +
+ ); +} +``` + +**Remember:** singleton pattern with `getInstance()`, `private constructor`, atoms on the model, simple-as-fields, ctor for dependent/derived, updates via `globalStore.set/get`. +**Note** Older models may not use the singleton pattern + +### Tool Use + +Do NOT use write_to_file unless it is a new file or very short. Always prefer to use replace_in_file. Often your diffs fail when a file may be out of date in your cache vs the actual on-disk format. You should RE-READ the file and try to create diffs again if your diffs fail rather than fall back to write_to_file. If you feel like your ONLY option is to use write_to_file please ask first. + +Also when adding content to the end of files prefer to use the new append_file tool rather than trying to create a diff (as your diffs are often not specific enough and end up inserting code in the middle of existing functions). + +### Directory Awareness + +- **ALWAYS verify the current working directory before executing commands** +- Either run "pwd" first to verify the directory, or do a "cd" to the correct absolute directory before running commands +- When running tests, do not "cd" to the pkg directory and then run the test. This screws up the cwd and you never recover. run the test from the project root instead. + +### Testing / Compiling Go Code + +No need to run a `go build` or a `go run` to just check if the Go code compiles. VSCode's errors/problems cover this well. +If there are no Go errors in VSCode you can assume the code compiles fine. diff --git a/.kilocode/skills/add-config/SKILL.md b/.kilocode/skills/add-config/SKILL.md new file mode 100644 index 000000000..f961093bb --- /dev/null +++ b/.kilocode/skills/add-config/SKILL.md @@ -0,0 +1,471 @@ +--- +name: add-config +description: Guide for adding new configuration settings to Wave Terminal. Use when adding a new setting to the configuration system, implementing a new config key, or adding user-customizable settings. +--- + +# Adding a New Configuration Setting to Wave Terminal + +This guide explains how to add a new configuration setting to Wave Terminal's hierarchical configuration system. + +## Configuration System Overview + +Wave Terminal uses a hierarchical configuration system with: + +1. **Go Struct Definitions** - Type-safe configuration structure in `pkg/wconfig/settingsconfig.go` +2. **JSON Schema** - Auto-generated validation schema in `schema/settings.json` +3. **Default Values** - Built-in defaults in `pkg/wconfig/defaultconfig/settings.json` +4. **User Configuration** - User overrides in `~/.config/waveterm/settings.json` +5. **Block Metadata** - Block-level overrides in `pkg/waveobj/wtypemeta.go` +6. **Documentation** - User-facing docs in `docs/docs/config.mdx` + +Settings cascade from defaults → user settings → connection config → block overrides. + +## Step-by-Step Guide + +### Step 1: Add to Go Struct Definition + +Edit `pkg/wconfig/settingsconfig.go` and add your new field to the `SettingsType` struct: + +```go +type SettingsType struct { + // ... existing fields ... + + // Add your new field with appropriate JSON tag + MyNewSetting string `json:"mynew:setting,omitempty"` + + // For different types: + MyBoolSetting bool `json:"mynew:boolsetting,omitempty"` + MyNumberSetting float64 `json:"mynew:numbersetting,omitempty"` + MyIntSetting *int64 `json:"mynew:intsetting,omitempty"` // Use pointer for optional ints + MyArraySetting []string `json:"mynew:arraysetting,omitempty"` +} +``` + +**Naming Conventions:** + +- Use namespace prefixes (e.g., `term:`, `window:`, `ai:`, `web:`, `app:`) +- Use lowercase with colons as separators +- Field names should be descriptive and follow Go naming conventions +- Use `omitempty` tag to exclude empty values from JSON + +**Type Guidelines:** + +- Use `*int64` and `*float64` for optional numeric values +- Use `*bool` for optional boolean values (or `bool` if default is false) +- Use `string` for text values +- Use `[]string` for arrays +- Use `float64` for numbers that can be decimals + +**Namespace Organization:** + +- `app:*` - Application-level settings +- `term:*` - Terminal-specific settings +- `window:*` - Window and UI settings +- `ai:*` - AI-related settings +- `web:*` - Web browser settings +- `editor:*` - Code editor settings +- `conn:*` - Connection settings + +### Step 1.5: Add to Block Metadata (Optional) + +If your setting should support block-level overrides, also add it to `pkg/waveobj/wtypemeta.go`: + +```go +type MetaTSType struct { + // ... existing fields ... + + // Add your new field with matching JSON tag and type + MyNewSetting *string `json:"mynew:setting,omitempty"` // Use pointer for optional values + + // For different types: + MyBoolSetting *bool `json:"mynew:boolsetting,omitempty"` + MyNumberSetting *float64 `json:"mynew:numbersetting,omitempty"` + MyIntSetting *int `json:"mynew:intsetting,omitempty"` + MyArraySetting []string `json:"mynew:arraysetting,omitempty"` +} +``` + +**Block Metadata Guidelines:** + +- Use pointer types (`*string`, `*bool`, `*int`, `*float64`) for optional overrides +- JSON tags should exactly match the corresponding settings field +- This enables the hierarchical config system: block metadata → connection config → global settings +- Only add settings here that make sense to override per-block or per-connection + +### Step 2: Set Default Value (Optional) + +If your setting should have a default value, add it to `pkg/wconfig/defaultconfig/settings.json`: + +```json +{ + "ai:preset": "ai@global", + "ai:model": "gpt-5-mini", + // ... existing defaults ... + + "mynew:setting": "default value", + "mynew:boolsetting": true, + "mynew:numbersetting": 42.5, + "mynew:intsetting": 100 +} +``` + +**Default Value Guidelines:** + +- Only add defaults for settings that should have non-zero/non-empty initial values +- Ensure defaults make sense for typical user experience +- Keep defaults conservative and safe +- Boolean settings often don't need defaults if `false` is the correct default + +### Step 3: Update Documentation + +Add your new setting to the configuration table in `docs/docs/config.mdx`: + +```markdown +| Key Name | Type | Function | +| ------------------- | -------- | ----------------------------------------- | +| mynew:setting | string | Description of what this setting controls | +| mynew:boolsetting | bool | Enable/disable some feature | +| mynew:numbersetting | float | Numeric setting for some parameter | +| mynew:intsetting | int | Integer setting for some configuration | +| mynew:arraysetting | string[] | Array of strings for multiple values | +``` + +**Documentation Guidelines:** + +- Provide clear, concise descriptions +- For new settings in upcoming releases, add `` +- Update the default configuration example if you added defaults +- Explain what values are valid and what they do + +### Step 4: Regenerate Schema and TypeScript Types + +Run the generate task to automatically regenerate the JSON schema and TypeScript types: + +```bash +task generate +``` + +**What this does:** + +- Runs `task build:schema` (automatically generates JSON schema from Go structs) +- Generates TypeScript type definitions in `frontend/types/gotypes.d.ts` +- Generates RPC client APIs +- Generates metadata constants + +**Important:** The JSON schema in `schema/settings.json` is **automatically generated** from the Go struct definitions - you don't need to edit it manually. + +### Step 5: Use in Frontend Code + +Access your new setting in React components: + +```typescript +import { getOverrideConfigAtom, getSettingsKeyAtom, useAtomValue } from "@/store/global"; + +// In a React component +const MyComponent = ({ blockId }: { blockId: string }) => { + // Use override config atom for hierarchical resolution + // This automatically checks: block metadata → connection config → global settings → default + const mySettingAtom = getOverrideConfigAtom(blockId, "mynew:setting"); + const mySetting = useAtomValue(mySettingAtom) ?? "fallback value"; + + // For global-only settings (no block overrides) + const globalOnlySetting = useAtomValue(getSettingsKeyAtom("mynew:globalsetting")) ?? "fallback"; + + return
Setting value: {mySetting}
; +}; +``` + +**Frontend Configuration Patterns:** + +```typescript +// 1. Settings with block-level overrides (recommended for most view/display settings) +const termFontSize = useAtomValue(getOverrideConfigAtom(blockId, "term:fontsize")) ?? 12; + +// 2. Global-only settings (app-wide settings that don't vary by block) +const appGlobalHotkey = useAtomValue(getSettingsKeyAtom("app:globalhotkey")) ?? ""; + +// 3. Connection-specific settings +const connStatus = useAtomValue(getConnStatusAtom(connectionName)); +``` + +**When to use each pattern:** + +- Use `getOverrideConfigAtom()` for settings that can vary by block or connection (most UI/display settings) +- Use `getSettingsKeyAtom()` for app-level settings that are always global +- Always provide a fallback value with `??` operator + +### Step 6: Use in Backend Code + +Access settings in Go code: + +```go +// Get the full config +fullConfig := wconfig.GetWatcher().GetFullConfig() + +// Access your setting +myValue := fullConfig.Settings.MyNewSetting + +// For optional values (pointers) +if fullConfig.Settings.MyIntSetting != nil { + intValue := *fullConfig.Settings.MyIntSetting + // Use intValue +} +``` + +## Complete Examples + +### Example 1: Simple Boolean Setting (No Block Override) + +**Use case:** Add a setting to hide the AI button globally + +#### 1. Go Struct (`pkg/wconfig/settingsconfig.go`) + +```go +type SettingsType struct { + // ... existing fields ... + AppHideAiButton bool `json:"app:hideaibutton,omitempty"` +} +``` + +#### 2. Default Value (`pkg/wconfig/defaultconfig/settings.json`) + +```json +{ + "app:hideaibutton": false +} +``` + +#### 3. Documentation (`docs/docs/config.mdx`) + +```markdown +| app:hideaibutton | bool | Hide the AI button in the tab bar (defaults to false) | +``` + +#### 4. Generate Types + +```bash +task generate +``` + +#### 5. Frontend Usage + +```typescript +import { getSettingsKeyAtom } from "@/store/global"; + +const TabBar = () => { + const hideAiButton = useAtomValue(getSettingsKeyAtom("app:hideaibutton")); + + if (hideAiButton) { + return null; // Don't render AI button + } + + return ; +}; +``` + +#### 6. Usage Examples + +```bash +# Set in settings file +wsh setconfig app:hideaibutton=true + +# Or edit ~/.config/waveterm/settings.json +{ + "app:hideaibutton": true +} +``` + +### Example 2: Terminal Setting with Block Override + +**Use case:** Add a terminal bell sound setting that can be overridden per block + +#### 1. Go Struct (`pkg/wconfig/settingsconfig.go`) + +```go +type SettingsType struct { + // ... existing fields ... + TermBellSound string `json:"term:bellsound,omitempty"` +} +``` + +#### 2. Block Metadata (`pkg/waveobj/wtypemeta.go`) + +```go +type MetaTSType struct { + // ... existing fields ... + TermBellSound *string `json:"term:bellsound,omitempty"` // Pointer for optional override +} +``` + +#### 3. Default Value (`pkg/wconfig/defaultconfig/settings.json`) + +```json +{ + "term:bellsound": "default" +} +``` + +#### 4. Documentation (`docs/docs/config.mdx`) + +```markdown +| term:bellsound | string | Sound to play for terminal bell ("default", "none", or custom sound file path) | +``` + +#### 5. Generate Types + +```bash +task generate +``` + +#### 6. Frontend Usage + +```typescript +import { getOverrideConfigAtom } from "@/store/global"; + +const TerminalView = ({ blockId }: { blockId: string }) => { + // Use override config for hierarchical resolution + const bellSoundAtom = getOverrideConfigAtom(blockId, "term:bellsound"); + const bellSound = useAtomValue(bellSoundAtom) ?? "default"; + + const playBellSound = () => { + if (bellSound === "none") return; + // Play the bell sound + }; + + return
Terminal with bell: {bellSound}
; +}; +``` + +#### 7. Usage Examples + +```bash +# Set globally in settings file +wsh setconfig term:bellsound="custom.wav" + +# Set for current block only +wsh setmeta term:bellsound="none" + +# Set for specific block +wsh setmeta --block BLOCK_ID term:bellsound="beep" + +# Or edit ~/.config/waveterm/settings.json +{ + "term:bellsound": "custom.wav" +} +``` + +## Configuration Patterns + +### Clear/Reset Pattern + +Each namespace can have a "clear" field for resetting all settings in that namespace: + +```go +AppClear bool `json:"app:*,omitempty"` +TermClear bool `json:"term:*,omitempty"` +``` + +### Optional vs Required Settings + +- Use pointer types (`*bool`, `*int64`, `*float64`) for truly optional settings +- Use regular types for settings that should always have a value +- Provide sensible defaults for important settings + +### Block-Level Overrides via RPC + +Settings can be overridden at the block level using metadata: + +```typescript +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { WOS } from "@/store/global"; + +// Set block-specific override +await RpcApi.SetMetaCommand(TabRpcClient, { + oref: WOS.makeORef("block", blockId), + meta: { "mynew:setting": "block-specific value" }, +}); +``` + +## Common Pitfalls + +### 1. Forgetting to Run `task generate` + +**Problem:** TypeScript types not updated, schema out of sync + +**Solution:** Always run `task generate` after modifying Go structs + +### 2. Type Mismatch Between Settings and Metadata + +**Problem:** Settings uses `string`, metadata uses `*int` + +**Solution:** Ensure types match (except metadata uses pointers for optionals) + +### 3. Not Providing Fallback Values + +**Problem:** Component breaks if setting is undefined + +**Solution:** Always use `??` operator with fallback: + +```typescript +const value = useAtomValue(getSettingsKeyAtom("key")) ?? "default"; +``` + +### 4. Using Wrong Config Atom + +**Problem:** Using `getSettingsKeyAtom()` for settings that need block overrides + +**Solution:** Use `getOverrideConfigAtom()` for any setting in `MetaTSType` + +## Best Practices + +### Naming + +- **Use descriptive names**: `term:fontsize` not `term:fs` +- **Follow namespace conventions**: Group related settings with common prefix +- **Use consistent casing**: Always lowercase with colons + +### Types + +- **Use `bool`** for simple on/off settings (no pointer if false is default) +- **Use `*bool`** only if you need to distinguish unset from false +- **Use `*int64`/`*float64`** for optional numeric values +- **Use `string`** for text, paths, or enum-like values +- **Use `[]string`** for lists + +### Defaults + +- **Provide sensible defaults** for settings users will commonly change +- **Omit defaults** for advanced/optional settings +- **Keep defaults safe** - don't enable experimental features by default +- **Document defaults** clearly in config.mdx + +### Block Overrides + +- **Enable for view/display settings**: Font sizes, colors, themes, etc. +- **Don't enable for app-wide settings**: Global hotkeys, window behavior, etc. +- **Consider the use case**: Would a user want different values per block or connection? + +### Documentation + +- **Be specific**: Explain what the setting does and what values are valid +- **Provide examples**: Show common use cases +- **Add version badges**: Mark new settings with `` +- **Keep it current**: Update docs when behavior changes + +## Quick Reference + +When adding a new configuration setting: + +- [ ] Add field to `SettingsType` in `pkg/wconfig/settingsconfig.go` +- [ ] Add field to `MetaTSType` in `pkg/waveobj/wtypemeta.go` (if block override needed) +- [ ] Add default to `pkg/wconfig/defaultconfig/settings.json` (if needed) +- [ ] Document in `docs/docs/config.mdx` +- [ ] Run `task generate` to update TypeScript types +- [ ] Use appropriate atom (`getOverrideConfigAtom` or `getSettingsKeyAtom`) in frontend + +## Related Documentation + +- **User Documentation**: `docs/docs/config.mdx` - User-facing configuration docs +- **Type Definitions**: `pkg/wconfig/settingsconfig.go` - Go struct definitions +- **Metadata Types**: `pkg/waveobj/wtypemeta.go` - Block metadata definitions diff --git a/.kilocode/skills/add-rpc/SKILL.md b/.kilocode/skills/add-rpc/SKILL.md new file mode 100644 index 000000000..0bf5117f9 --- /dev/null +++ b/.kilocode/skills/add-rpc/SKILL.md @@ -0,0 +1,453 @@ +--- +name: add-rpc +description: Guide for adding new RPC calls to Wave Terminal. Use when implementing new RPC commands, adding server-client communication methods, or extending the RPC interface with new functionality. +--- + +# Adding RPC Calls Guide + +## Overview + +Wave Terminal uses a WebSocket-based RPC (Remote Procedure Call) system for communication between different components. The RPC system allows the frontend, backend, electron main process, remote servers, and terminal blocks to communicate with each other through well-defined commands. + +This guide covers how to add a new RPC command to the system. + +## Key Files + +- `pkg/wshrpc/wshrpctypes.go` - RPC interface and type definitions +- `pkg/wshrpc/wshserver/wshserver.go` - Main server implementation (most common) +- `emain/emain-wsh.ts` - Electron main process implementation +- `frontend/app/store/tabrpcclient.ts` - Frontend tab implementation +- `pkg/wshrpc/wshremote/wshremote.go` - Remote server implementation +- `frontend/app/view/term/term-wsh.tsx` - Terminal block implementation + +## RPC Command Structure + +RPC commands in Wave Terminal follow these conventions: + +- **Method names** must end with `Command` +- **First parameter** must be `context.Context` +- **Remaining parameters** are a regular Go parameter list (zero or more typed args) +- **Return values** can be either just an error, or one return value plus an error +- **Streaming commands** return a channel instead of a direct value + +## Adding a New RPC Call + +### Step 1: Define the Command in the Interface + +Add your command to the `WshRpcInterface` in `pkg/wshrpc/wshrpctypes.go`: + +```go +type WshRpcInterface interface { + // ... existing commands ... + + // Add your new command + YourNewCommand(ctx context.Context, data CommandYourNewData) (*YourNewResponse, error) +} +``` + +**Method Signature Rules:** + +- Method name must end with `Command` +- First parameter must be `ctx context.Context` +- Remaining parameters are a regular Go parameter list (zero or more) +- Return either `error` or `(ReturnType, error)` +- For streaming, return `chan RespOrErrorUnion[T]` + +### Step 2: Define Request and Response Types + +If your command needs structured input or output, define types in the same file: + +```go +type CommandYourNewData struct { + FieldOne string `json:"fieldone"` + FieldTwo int `json:"fieldtwo"` + SomeId string `json:"someid"` +} + +type YourNewResponse struct { + ResultField string `json:"resultfield"` + Success bool `json:"success"` +} +``` + +**Type Naming Conventions:** + +- Request types: `Command[Name]Data` (e.g., `CommandGetMetaData`) +- Response types: `[Name]Response` or `Command[Name]RtnData` (e.g., `CommandResolveIdsRtnData`) +- Use `json` struct tags with lowercase field names +- Follow existing patterns in the file for consistency + +### Step 3: Generate Bindings + +After modifying `pkg/wshrpc/wshrpctypes.go`, run code generation to create TypeScript bindings and Go helper code: + +```bash +task generate +``` + +This command will: +- Generate TypeScript type definitions in `frontend/types/gotypes.d.ts` +- Create RPC client bindings +- Update routing code + +**Note:** If generation fails, check that your method signature follows all the rules above. + +### Step 4: Implement the Command + +Choose where to implement your command based on what it needs to do: + +#### A. Main Server Implementation (Most Common) + +Implement in `pkg/wshrpc/wshserver/wshserver.go`: + +```go +func (ws *WshServer) YourNewCommand(ctx context.Context, data wshrpc.CommandYourNewData) (*wshrpc.YourNewResponse, error) { + // Validate input + if data.SomeId == "" { + return nil, fmt.Errorf("someid is required") + } + + // Implement your logic + result := doSomething(data) + + // Return response + return &wshrpc.YourNewResponse{ + ResultField: result, + Success: true, + }, nil +} +``` + +**Use main server when:** +- Accessing the database +- Managing blocks, tabs, or workspaces +- Coordinating between components +- Handling file operations on the main filesystem + +#### B. Electron Implementation + +Implement in `emain/emain-wsh.ts`: + +```typescript +async handle_yournew(rh: RpcResponseHelper, data: CommandYourNewData): Promise { + // Electron-specific logic + const result = await electronAPI.doSomething(data); + + return { + resultfield: result, + success: true, + }; +} +``` + +**Use Electron when:** +- Accessing native OS features +- Managing application windows +- Using Electron APIs (notifications, system tray, etc.) +- Handling encryption/decryption with safeStorage + +#### C. Frontend Tab Implementation + +Implement in `frontend/app/store/tabrpcclient.ts`: + +```typescript +async handle_yournew(rh: RpcResponseHelper, data: CommandYourNewData): Promise { + // Access frontend state/models + const layoutModel = getLayoutModelForStaticTab(); + + // Implement tab-specific logic + const result = layoutModel.doSomething(data); + + return { + resultfield: result, + success: true, + }; +} +``` + +**Use tab client when:** +- Accessing React state or Jotai atoms +- Manipulating UI layout +- Capturing screenshots +- Reading frontend-only data + +#### D. Remote Server Implementation + +Implement in `pkg/wshrpc/wshremote/wshremote.go`: + +```go +func (impl *ServerImpl) RemoteYourNewCommand(ctx context.Context, data wshrpc.CommandRemoteYourNewData) (*wshrpc.YourNewResponse, error) { + // Remote filesystem or process operations + result, err := performRemoteOperation(data) + if err != nil { + return nil, fmt.Errorf("remote operation failed: %w", err) + } + + return &wshrpc.YourNewResponse{ + ResultField: result, + Success: true, + }, nil +} +``` + +**Use remote server when:** +- Operating on remote filesystems +- Executing commands on remote hosts +- Managing remote processes +- Convention: prefix command name with `Remote` (e.g., `RemoteGetInfoCommand`) + +#### E. Terminal Block Implementation + +Implement in `frontend/app/view/term/term-wsh.tsx`: + +```typescript +async handle_yournew(rh: RpcResponseHelper, data: CommandYourNewData): Promise { + // Access terminal-specific data + const termWrap = this.model.termRef.current; + + // Implement terminal logic + const result = termWrap.doSomething(data); + + return { + resultfield: result, + success: true, + }; +} +``` + +**Use terminal client when:** +- Accessing terminal buffer/scrollback +- Managing VDOM contexts +- Reading terminal-specific state +- Interacting with xterm.js + +## Complete Example: Adding GetWaveInfo Command + +### 1. Define Interface + +In `pkg/wshrpc/wshrpctypes.go`: + +```go +type WshRpcInterface interface { + // ... other commands ... + WaveInfoCommand(ctx context.Context) (*WaveInfoData, error) +} + +type WaveInfoData struct { + Version string `json:"version"` + BuildTime string `json:"buildtime"` + ConfigPath string `json:"configpath"` + DataPath string `json:"datapath"` +} +``` + +### 2. Generate Bindings + +```bash +task generate +``` + +### 3. Implement in Main Server + +In `pkg/wshrpc/wshserver/wshserver.go`: + +```go +func (ws *WshServer) WaveInfoCommand(ctx context.Context) (*wshrpc.WaveInfoData, error) { + return &wshrpc.WaveInfoData{ + Version: wavebase.WaveVersion, + BuildTime: wavebase.BuildTime, + ConfigPath: wavebase.GetConfigDir(), + DataPath: wavebase.GetWaveDataDir(), + }, nil +} +``` + +### 4. Call from Frontend + +```typescript +import { RpcApi } from "@/app/store/wshclientapi"; + +// Call the RPC +const info = await RpcApi.WaveInfoCommand(TabRpcClient); +console.log("Wave Version:", info.version); +``` + +## Streaming Commands + +For commands that return data progressively, use channels: + +### Define Streaming Interface + +```go +type WshRpcInterface interface { + StreamYourDataCommand(ctx context.Context, request YourDataRequest) chan RespOrErrorUnion[YourDataType] +} +``` + +### Implement Streaming Command + +```go +func (ws *WshServer) StreamYourDataCommand(ctx context.Context, request wshrpc.YourDataRequest) chan wshrpc.RespOrErrorUnion[wshrpc.YourDataType] { + rtn := make(chan wshrpc.RespOrErrorUnion[wshrpc.YourDataType]) + + go func() { + defer close(rtn) + defer func() { + panichandler.PanicHandler("StreamYourDataCommand", recover()) + }() + + // Stream data + for i := 0; i < 10; i++ { + select { + case <-ctx.Done(): + return + default: + rtn <- wshrpc.RespOrErrorUnion[wshrpc.YourDataType]{ + Response: wshrpc.YourDataType{ + Value: i, + }, + } + time.Sleep(100 * time.Millisecond) + } + } + }() + + return rtn +} +``` + +## Best Practices + +1. **Validation First**: Always validate input parameters at the start of your implementation + +2. **Descriptive Names**: Use clear, action-oriented command names (e.g., `GetFullConfigCommand`, not `ConfigCommand`) + +3. **Error Handling**: Return descriptive errors with context: + ```go + return nil, fmt.Errorf("error creating block: %w", err) + ``` + +4. **Context Awareness**: Respect context cancellation for long-running operations: + ```go + select { + case <-ctx.Done(): + return ctx.Err() + default: + // continue + } + ``` + +5. **Consistent Types**: Follow existing naming patterns for request/response types + +6. **JSON Tags**: Always use lowercase JSON tags matching frontend conventions + +7. **Documentation**: Add comments explaining complex commands or special behaviors + +8. **Type Safety**: Leverage TypeScript generation - your types will be checked on both ends + +9. **Panic Recovery**: Use `panichandler.PanicHandler` in goroutines to prevent crashes + +10. **Route Awareness**: For multi-route scenarios, use `wshutil.GetRpcSourceFromContext(ctx)` to identify callers + +## Common Command Patterns + +### Simple Query + +```go +func (ws *WshServer) GetSomethingCommand(ctx context.Context, id string) (*Something, error) { + obj, err := wstore.DBGet[*Something](ctx, id) + if err != nil { + return nil, fmt.Errorf("error getting something: %w", err) + } + return obj, nil +} +``` + +### Mutation with Updates + +```go +func (ws *WshServer) UpdateSomethingCommand(ctx context.Context, data wshrpc.CommandUpdateData) error { + ctx = waveobj.ContextWithUpdates(ctx) + + // Make changes + err := wstore.UpdateObject(ctx, data.ORef, data.Updates) + if err != nil { + return fmt.Errorf("error updating: %w", err) + } + + // Broadcast updates + updates := waveobj.ContextGetUpdatesRtn(ctx) + wps.Broker.SendUpdateEvents(updates) + + return nil +} +``` + +### Command with Side Effects + +```go +func (ws *WshServer) DoActionCommand(ctx context.Context, data wshrpc.CommandActionData) error { + // Perform action + result, err := performAction(data) + if err != nil { + return err + } + + // Publish event about the action + go func() { + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_ActionComplete, + Data: result, + }) + }() + + return nil +} +``` + +## Troubleshooting + +### Command Not Found + +- Ensure method name ends with `Command` +- Verify you ran `task generate` +- Check that the interface is in `WshRpcInterface` + +### Type Mismatch Errors + +- Run `task generate` after changing types +- Ensure JSON tags are lowercase +- Verify TypeScript code is using generated types + +### Command Times Out + +- Check for blocking operations +- Ensure context is passed through +- Consider using a streaming command for long operations + +### Routing Issues + +- For remote commands, ensure they're implemented in correct location +- Check route configuration in RpcContext +- Verify authentication for secured routes + +## Quick Reference + +When adding a new RPC command: + +- [ ] Add method to `WshRpcInterface` in `pkg/wshrpc/wshrpctypes.go` (must end with `Command`) +- [ ] Define request/response types with JSON tags (if needed) +- [ ] Run `task generate` to create bindings +- [ ] Implement in appropriate location: + - [ ] `wshserver.go` for main server (most common) + - [ ] `emain-wsh.ts` for Electron + - [ ] `tabrpcclient.ts` for frontend + - [ ] `wshremote.go` for remote (prefix with `Remote`) + - [ ] `term-wsh.tsx` for terminal +- [ ] Add input validation +- [ ] Handle errors with context +- [ ] Test the command end-to-end + +## Related Documentation + +- **WPS Events**: See the `wps-events` skill - Publishing events from RPC commands diff --git a/.kilocode/skills/add-wshcmd/SKILL.md b/.kilocode/skills/add-wshcmd/SKILL.md new file mode 100644 index 000000000..ceb1e57e2 --- /dev/null +++ b/.kilocode/skills/add-wshcmd/SKILL.md @@ -0,0 +1,921 @@ +--- +name: add-wshcmd +description: Guide for adding new wsh commands to Wave Terminal. Use when implementing new CLI commands, adding command-line functionality, or extending the wsh command interface. +--- + +# Adding a New wsh Command to Wave Terminal + +This guide explains how to add a new command to the `wsh` CLI tool. + +## wsh Command System Overview + +Wave Terminal's `wsh` command provides CLI access to Wave Terminal features. The system uses: + +1. **Cobra Framework** - CLI command structure and parsing +2. **Command Files** - Individual command implementations in `cmd/wsh/cmd/wshcmd-*.go` +3. **RPC Client** - Communication with Wave Terminal backend via `RpcClient` +4. **Activity Tracking** - Telemetry for command usage analytics +5. **Documentation** - User-facing docs in `docs/docs/wsh-reference.mdx` + +Commands are registered in their `init()` functions and execute through the Cobra framework. + +## Step-by-Step Guide + +### Step 1: Create Command File + +Create a new file in `cmd/wsh/cmd/` named `wshcmd-[commandname].go`: + +```go +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +var myCommandCmd = &cobra.Command{ + Use: "mycommand [args]", + Short: "Brief description of what this command does", + Long: `Detailed description of the command. +Can include multiple lines and examples of usage.`, + RunE: myCommandRun, + PreRunE: preRunSetupRpcClient, // Include if command needs RPC + DisableFlagsInUseLine: true, +} + +// Flag variables +var ( + myCommandFlagExample string + myCommandFlagVerbose bool +) + +func init() { + // Add command to root + rootCmd.AddCommand(myCommandCmd) + + // Define flags + myCommandCmd.Flags().StringVarP(&myCommandFlagExample, "example", "e", "", "example flag description") + myCommandCmd.Flags().BoolVarP(&myCommandFlagVerbose, "verbose", "v", false, "enable verbose output") +} + +func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { + // Always track activity for telemetry + defer func() { + sendActivity("mycommand", rtnErr == nil) + }() + + // Validate arguments + if len(args) == 0 { + OutputHelpMessage(cmd) + return fmt.Errorf("requires at least one argument") + } + + // Command implementation + fmt.Printf("Command executed successfully\n") + return nil +} +``` + +**File Naming Convention:** +- Use `wshcmd-[commandname].go` format +- Use lowercase, hyphenated names for multi-word commands +- Examples: `wshcmd-getvar.go`, `wshcmd-setmeta.go`, `wshcmd-ai.go` + +### Step 2: Command Structure + +#### Basic Command Structure + +```go +var myCommandCmd = &cobra.Command{ + Use: "mycommand [required] [optional...]", + Short: "One-line description (shown in help)", + Long: `Detailed multi-line description`, + + // Argument validation + Args: cobra.MinimumNArgs(1), // Or cobra.ExactArgs(1), cobra.NoArgs, etc. + + // Execution function + RunE: myCommandRun, + + // Pre-execution setup (if needed) + PreRunE: preRunSetupRpcClient, // Sets up RPC client for backend communication + + // Example usage (optional) + Example: " wsh mycommand foo\n wsh mycommand --flag bar", + + // Disable flag notation in usage line + DisableFlagsInUseLine: true, +} +``` + +**Key Fields:** +- `Use`: Command name and argument pattern +- `Short`: Brief description for command list +- `Long`: Detailed description shown in help +- `Args`: Argument validator (optional) +- `RunE`: Main execution function (returns error) +- `PreRunE`: Setup function that runs before `RunE` +- `Example`: Usage examples (optional) +- `DisableFlagsInUseLine`: Clean up help display + +#### When to Use PreRunE + +Include `PreRunE: preRunSetupRpcClient` if your command: +- Communicates with the Wave Terminal backend +- Needs access to `RpcClient` +- Requires JWT authentication (WAVETERM_JWT env var) +- Makes RPC calls via `wshclient.*Command()` functions + +**Don't include PreRunE** for commands that: +- Only manipulate local state +- Don't need backend communication +- Are purely informational/local operations + +### Step 3: Implement Command Logic + +#### Command Function Pattern + +```go +func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { + // Step 1: Always track activity (for telemetry) + defer func() { + sendActivity("mycommand", rtnErr == nil) + }() + + // Step 2: Validate arguments and flags + if len(args) != 1 { + OutputHelpMessage(cmd) + return fmt.Errorf("requires exactly one argument") + } + + // Step 3: Parse/prepare data + targetArg := args[0] + + // Step 4: Make RPC call if needed + result, err := wshclient.SomeCommand(RpcClient, wshrpc.CommandSomeData{ + Field: targetArg, + }, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("executing command: %w", err) + } + + // Step 5: Output results + fmt.Printf("Result: %s\n", result) + return nil +} +``` + +**Important Patterns:** + +1. **Activity Tracking**: Always include deferred `sendActivity()` call + ```go + defer func() { + sendActivity("commandname", rtnErr == nil) + }() + ``` + +2. **Error Handling**: Return errors, don't call `os.Exit()` + ```go + if err != nil { + return fmt.Errorf("context: %w", err) + } + ``` + +3. **Output**: Use standard `fmt` package for output + ```go + fmt.Printf("Success message\n") + fmt.Fprintf(os.Stderr, "Error message\n") + ``` + +4. **Help Messages**: Show help when arguments are invalid + ```go + if len(args) == 0 { + OutputHelpMessage(cmd) + return fmt.Errorf("requires arguments") + } + ``` + +5. **Exit Codes**: Set custom exit code via `WshExitCode` + ```go + if notFound { + WshExitCode = 1 + return nil // Don't return error, just set exit code + } + ``` + +### Step 4: Define Flags + +Add flags in the `init()` function: + +```go +var ( + // Declare flag variables at package level + myCommandFlagString string + myCommandFlagBool bool + myCommandFlagInt int +) + +func init() { + rootCmd.AddCommand(myCommandCmd) + + // String flag with short version + myCommandCmd.Flags().StringVarP(&myCommandFlagString, "name", "n", "default", "description") + + // Boolean flag + myCommandCmd.Flags().BoolVarP(&myCommandFlagBool, "verbose", "v", false, "enable verbose") + + // Integer flag + myCommandCmd.Flags().IntVar(&myCommandFlagInt, "count", 10, "set count") + + // Flag without short version + myCommandCmd.Flags().StringVar(&myCommandFlagString, "longname", "", "description") +} +``` + +**Flag Types:** +- `StringVar/StringVarP` - String values +- `BoolVar/BoolVarP` - Boolean flags +- `IntVar/IntVarP` - Integer values +- The `P` suffix versions include a short flag name + +**Flag Naming:** +- Use camelCase for variable names: `myCommandFlagName` +- Use kebab-case for flag names: `--flag-name` +- Prefix variable names with command name for clarity + +### Step 5: Working with Block Arguments + +Many commands operate on blocks. Use the standard block resolution pattern: + +```go +func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("mycommand", rtnErr == nil) + }() + + // Resolve block using the -b/--block flag + fullORef, err := resolveBlockArg() + if err != nil { + return err + } + + // Use the blockid in RPC call + err = wshclient.SomeCommand(RpcClient, wshrpc.CommandSomeData{ + BlockId: fullORef.OID, + }, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("command failed: %w", err) + } + + return nil +} +``` + +**Block Resolution:** +- The `-b/--block` flag is defined globally in `wshcmd-root.go` +- `resolveBlockArg()` resolves the block argument to a full ORef +- Supports: `this`, `tab`, full UUIDs, 8-char prefixes, block numbers +- Default is `"this"` (current block) + +**Alternative: Manual Block Resolution** + +```go +// Get tab ID from environment +tabId := os.Getenv("WAVETERM_TABID") +if tabId == "" { + return fmt.Errorf("WAVETERM_TABID not set") +} + +// Create route for tab-level operations +route := wshutil.MakeTabRouteId(tabId) + +// Use route in RPC call +err := wshclient.SomeCommand(RpcClient, commandData, &wshrpc.RpcOpts{ + Route: route, + Timeout: 2000, +}) +``` + +### Step 6: Making RPC Calls + +Use the `wshclient` package to make RPC calls: + +```go +import ( + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +// Simple RPC call +result, err := wshclient.GetMetaCommand(RpcClient, wshrpc.CommandGetMetaData{ + ORef: *fullORef, +}, &wshrpc.RpcOpts{Timeout: 2000}) +if err != nil { + return fmt.Errorf("getting metadata: %w", err) +} + +// RPC call with routing +err := wshclient.SetMetaCommand(RpcClient, wshrpc.CommandSetMetaData{ + ORef: *fullORef, + Meta: metaMap, +}, &wshrpc.RpcOpts{ + Route: route, + Timeout: 5000, +}) +if err != nil { + return fmt.Errorf("setting metadata: %w", err) +} +``` + +**RPC Options:** +- `Timeout`: Request timeout in milliseconds (typically 2000-5000) +- `Route`: Route ID for targeting specific components +- Available routes: `wshutil.ControlRoute`, `wshutil.MakeTabRouteId(tabId)` + +### Step 7: Add Documentation + +Add your command to `docs/docs/wsh-reference.mdx`: + +````markdown +## mycommand + +Brief description of what the command does. + +```sh +wsh mycommand [args] [flags] +``` + +Detailed explanation of the command's purpose and behavior. + +Flags: +- `-n, --name ` - description of this flag +- `-v, --verbose` - enable verbose output +- `-b, --block ` - specify target block (default: current block) + +Examples: + +```sh +# Basic usage +wsh mycommand arg1 + +# With flags +wsh mycommand --name value arg1 + +# With block targeting +wsh mycommand -b 2 arg1 + +# Complex example +wsh mycommand -v --name "example" arg1 arg2 +``` + +Additional notes, tips, or warnings about the command. + +--- +```` + +**Documentation Guidelines:** +- Place in alphabetical order with other commands +- Include command signature with argument pattern +- List all flags with short and long versions +- Provide practical examples (at least 3-5) +- Explain common use cases and patterns +- Add tips or warnings if relevant +- Use `---` separator between commands + +### Step 8: Test Your Command + +Build and test the command: + +```bash +# Build wsh +task build:wsh + +# Or build everything +task build + +# Test the command +./bin/wsh/wsh mycommand --help +./bin/wsh/wsh mycommand arg1 arg2 +``` + +**Testing Checklist:** +- [ ] Help message displays correctly +- [ ] Required arguments validated +- [ ] Flags work as expected +- [ ] Error messages are clear +- [ ] Success cases work correctly +- [ ] RPC calls complete successfully +- [ ] Output is formatted correctly + +## Complete Examples + +### Example 1: Simple Command with No RPC + +**Use case:** A command that prints Wave Terminal version info + +#### Command File (`cmd/wsh/cmd/wshcmd-version.go`) + +```go +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/wavebase" +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print Wave Terminal version", + RunE: versionRun, +} + +func init() { + rootCmd.AddCommand(versionCmd) +} + +func versionRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("version", rtnErr == nil) + }() + + fmt.Printf("Wave Terminal %s\n", wavebase.WaveVersion) + return nil +} +``` + +#### Documentation + +````markdown +## version + +Print the current Wave Terminal version. + +```sh +wsh version +``` + +Examples: + +```sh +# Print version +wsh version +``` +```` + +### Example 2: Command with Flags and RPC + +**Use case:** A command to update block title + +#### Command File (`cmd/wsh/cmd/wshcmd-settitle.go`) + +```go +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +var setTitleCmd = &cobra.Command{ + Use: "settitle [title]", + Short: "Set block title", + Long: `Set the title for the current or specified block.`, + Args: cobra.ExactArgs(1), + RunE: setTitleRun, + PreRunE: preRunSetupRpcClient, + DisableFlagsInUseLine: true, +} + +var setTitleIcon string + +func init() { + rootCmd.AddCommand(setTitleCmd) + setTitleCmd.Flags().StringVarP(&setTitleIcon, "icon", "i", "", "set block icon") +} + +func setTitleRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("settitle", rtnErr == nil) + }() + + title := args[0] + + // Resolve block + fullORef, err := resolveBlockArg() + if err != nil { + return err + } + + // Build metadata map + meta := make(map[string]interface{}) + meta["title"] = title + if setTitleIcon != "" { + meta["icon"] = setTitleIcon + } + + // Make RPC call + err = wshclient.SetMetaCommand(RpcClient, wshrpc.CommandSetMetaData{ + ORef: *fullORef, + Meta: meta, + }, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("setting title: %w", err) + } + + fmt.Printf("title updated\n") + return nil +} +``` + +#### Documentation + +````markdown +## settitle + +Set the title for a block. + +```sh +wsh settitle [title] +``` + +Update the display title for the current or specified block. Optionally set an icon as well. + +Flags: +- `-i, --icon ` - set block icon along with title +- `-b, --block ` - specify target block (default: current block) + +Examples: + +```sh +# Set title for current block +wsh settitle "My Terminal" + +# Set title and icon +wsh settitle --icon "terminal" "Development Shell" + +# Set title for specific block +wsh settitle -b 2 "Build Output" +``` +```` + +### Example 3: Subcommands + +**Use case:** Command with multiple subcommands (like `wsh conn`) + +#### Command File (`cmd/wsh/cmd/wshcmd-mygroup.go`) + +```go +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +var myGroupCmd = &cobra.Command{ + Use: "mygroup", + Short: "Manage something", +} + +var myGroupListCmd = &cobra.Command{ + Use: "list", + Short: "List items", + RunE: myGroupListRun, + PreRunE: preRunSetupRpcClient, +} + +var myGroupAddCmd = &cobra.Command{ + Use: "add [name]", + Short: "Add an item", + Args: cobra.ExactArgs(1), + RunE: myGroupAddRun, + PreRunE: preRunSetupRpcClient, +} + +func init() { + // Add parent command + rootCmd.AddCommand(myGroupCmd) + + // Add subcommands + myGroupCmd.AddCommand(myGroupListCmd) + myGroupCmd.AddCommand(myGroupAddCmd) +} + +func myGroupListRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("mygroup:list", rtnErr == nil) + }() + + // Implementation + fmt.Printf("Listing items...\n") + return nil +} + +func myGroupAddRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("mygroup:add", rtnErr == nil) + }() + + name := args[0] + fmt.Printf("Adding item: %s\n", name) + return nil +} +``` + +#### Documentation + +````markdown +## mygroup + +Manage something with subcommands. + +### list + +List all items. + +```sh +wsh mygroup list +``` + +### add + +Add a new item. + +```sh +wsh mygroup add [name] +``` + +Examples: + +```sh +# List items +wsh mygroup list + +# Add an item +wsh mygroup add "new-item" +``` +```` + +## Common Patterns + +### Reading from Stdin + +```go +import "io" + +func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("mycommand", rtnErr == nil) + }() + + // Check if reading from stdin (using "-" convention) + var data []byte + var err error + + if len(args) > 0 && args[0] == "-" { + data, err = io.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("reading stdin: %w", err) + } + } else { + // Read from file or other source + data, err = os.ReadFile(args[0]) + if err != nil { + return fmt.Errorf("reading file: %w", err) + } + } + + // Process data + fmt.Printf("Read %d bytes\n", len(data)) + return nil +} +``` + +### JSON File Input + +```go +import ( + "encoding/json" + "io" +) + +func loadJSONFile(filepath string) (map[string]interface{}, error) { + var data []byte + var err error + + if filepath == "-" { + data, err = io.ReadAll(os.Stdin) + if err != nil { + return nil, fmt.Errorf("reading stdin: %w", err) + } + } else { + data, err = os.ReadFile(filepath) + if err != nil { + return nil, fmt.Errorf("reading file: %w", err) + } + } + + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("parsing JSON: %w", err) + } + + return result, nil +} +``` + +### Conditional Output (TTY Detection) + +```go +func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("mycommand", rtnErr == nil) + }() + + isTty := getIsTty() + + // Output value + fmt.Printf("%s", value) + + // Add newline only if TTY (for better piping experience) + if isTty { + fmt.Printf("\n") + } + + return nil +} +``` + +### Environment Variable Access + +```go +func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("mycommand", rtnErr == nil) + }() + + // Get block ID from environment + blockId := os.Getenv("WAVETERM_BLOCKID") + if blockId == "" { + return fmt.Errorf("WAVETERM_BLOCKID not set") + } + + // Get tab ID from environment + tabId := os.Getenv("WAVETERM_TABID") + if tabId == "" { + return fmt.Errorf("WAVETERM_TABID not set") + } + + fmt.Printf("Block: %s, Tab: %s\n", blockId, tabId) + return nil +} +``` + +## Best Practices + +### Command Design + +1. **Single Responsibility**: Each command should do one thing well +2. **Composable**: Design commands to work with pipes and other commands +3. **Consistent**: Follow existing wsh command patterns and conventions +4. **Documented**: Provide clear help text and examples + +### Error Handling + +1. **Context**: Wrap errors with context using `fmt.Errorf("context: %w", err)` +2. **User-Friendly**: Make error messages clear and actionable +3. **No Panics**: Return errors instead of calling `os.Exit()` or `panic()` +4. **Exit Codes**: Use `WshExitCode` for custom exit codes + +### Output + +1. **Structured**: Use consistent formatting for output +2. **Quiet by Default**: Only output what's necessary +3. **Verbose Flag**: Optionally provide `-v` for detailed output +4. **Stderr for Errors**: Use `fmt.Fprintf(os.Stderr, ...)` for error messages + +### Flags + +1. **Short Versions**: Provide `-x` short versions for common flags +2. **Sensible Defaults**: Choose defaults that work for most users +3. **Boolean Flags**: Use for on/off options +4. **String Flags**: Use for values that need user input + +### RPC Calls + +1. **Timeouts**: Always specify reasonable timeouts +2. **Error Context**: Wrap RPC errors with operation context +3. **Retries**: Don't retry automatically; let user retry command +4. **Routes**: Use appropriate routes for different operations + +## Common Pitfalls + +### 1. Forgetting Activity Tracking + +**Problem**: Command usage not tracked in telemetry + +**Solution**: Always include deferred `sendActivity()` call: +```go +defer func() { + sendActivity("commandname", rtnErr == nil) +}() +``` + +### 2. Using os.Exit() Instead of Returning Error + +**Problem**: Breaks defer statements and cleanup + +**Solution**: Return errors from RunE function: +```go +// Bad +if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) +} + +// Good +if err != nil { + return fmt.Errorf("operation failed: %w", err) +} +``` + +### 3. Not Validating Arguments + +**Problem**: Command crashes with nil pointer or index out of range + +**Solution**: Validate arguments early and show help: +```go +if len(args) == 0 { + OutputHelpMessage(cmd) + return fmt.Errorf("requires at least one argument") +} +``` + +### 4. Forgetting to Add to init() + +**Problem**: Command not available when running wsh + +**Solution**: Always add command in `init()` function: +```go +func init() { + rootCmd.AddCommand(myCommandCmd) +} +``` + +### 5. Inconsistent Output + +**Problem**: Inconsistent use of output methods + +**Solution**: Use standard `fmt` package functions: +```go +// For stdout +fmt.Printf("output\n") + +// For stderr +fmt.Fprintf(os.Stderr, "error message\n") +``` + +## Quick Reference Checklist + +When adding a new wsh command: + +- [ ] Create `cmd/wsh/cmd/wshcmd-[commandname].go` +- [ ] Define command struct with Use, Short, Long descriptions +- [ ] Add `PreRunE: preRunSetupRpcClient` if using RPC +- [ ] Implement command function with activity tracking +- [ ] Add command to `rootCmd` in `init()` function +- [ ] Define flags in `init()` function if needed +- [ ] Add documentation to `docs/docs/wsh-reference.mdx` +- [ ] Build and test: `task build:wsh` +- [ ] Test help: `wsh [commandname] --help` +- [ ] Test all flag combinations +- [ ] Test error cases + +## Related Files + +- **Root Command**: `cmd/wsh/cmd/wshcmd-root.go` - Main command setup and utilities +- **RPC Client**: `pkg/wshrpc/wshclient/` - Client functions for RPC calls +- **RPC Types**: `pkg/wshrpc/wshrpctypes.go` - RPC request/response data structures +- **Documentation**: `docs/docs/wsh-reference.mdx` - User-facing command reference +- **Examples**: `cmd/wsh/cmd/wshcmd-*.go` - Existing command implementations diff --git a/.kilocode/skills/context-menu/SKILL.md b/.kilocode/skills/context-menu/SKILL.md new file mode 100644 index 000000000..dda3b7b98 --- /dev/null +++ b/.kilocode/skills/context-menu/SKILL.md @@ -0,0 +1,160 @@ +--- +name: context-menu +description: Guide for creating and displaying context menus in Wave Terminal. Use when implementing right-click menus, adding context menu items, creating submenus, or handling menu interactions with checkboxes and separators. +--- + +# Context Menu Quick Reference + +This guide provides a quick overview of how to create and display a context menu using our system. + +--- + +## ContextMenuItem Type + +Define each menu item using the `ContextMenuItem` type: + +```ts +type ContextMenuItem = { + label?: string; + type?: "separator" | "normal" | "submenu" | "checkbox" | "radio"; + role?: string; // Electron role (optional) + click?: () => void; // Callback for item selection (not needed if role is set) + submenu?: ContextMenuItem[]; // For nested menus + checked?: boolean; // For checkbox or radio items + visible?: boolean; + enabled?: boolean; + sublabel?: string; +}; +``` + +--- + +## Import and Show the Menu + +Import the context menu module: + +```ts +import { ContextMenuModel } from "@/app/store/contextmenu"; +``` + +To display the context menu, call: + +```ts +ContextMenuModel.getInstance().showContextMenu(menu, event); +``` + +- **menu**: An array of `ContextMenuItem`. +- **event**: The mouse event that triggered the context menu (typically from an onContextMenu handler). + +--- + +## Basic Example + +A simple context menu with a separator: + +```ts +const menu: ContextMenuItem[] = [ + { + label: "New File", + click: () => { + /* create a new file */ + }, + }, + { + label: "New Folder", + click: () => { + /* create a new folder */ + }, + }, + { type: "separator" }, + { + label: "Rename", + click: () => { + /* rename item */ + }, + }, +]; + +ContextMenuModel.getInstance().showContextMenu(menu, e); +``` + +--- + +## Example with Submenu and Checkboxes + +Toggle settings using a submenu with checkbox items: + +```ts +const isClearOnStart = true; // Example setting + +const menu: ContextMenuItem[] = [ + { + label: "Clear Output On Restart", + submenu: [ + { + label: "On", + type: "checkbox", + checked: isClearOnStart, + click: () => { + // Set the config to enable clear on restart + }, + }, + { + label: "Off", + type: "checkbox", + checked: !isClearOnStart, + click: () => { + // Set the config to disable clear on restart + }, + }, + ], + }, +]; + +ContextMenuModel.getInstance().showContextMenu(menu, e); +``` + +--- + +## Editing a Config File Example + +Open a configuration file (e.g., `widgets.json`) in preview mode: + +```ts +{ + label: "Edit widgets.json", + click: () => { + fireAndForget(async () => { + const path = `${getApi().getConfigDir()}/widgets.json`; + const blockDef: BlockDef = { + meta: { view: "preview", file: path }, + }; + await createBlock(blockDef, false, true); + }); + }, +} +``` + +--- + +## Summary + +- **Menu Definition**: Use the `ContextMenuItem` type. +- **Actions**: Use `click` for actions; use `submenu` for nested options. +- **Separators**: Use `type: "separator"` to group items. +- **Toggles**: Use `type: "checkbox"` or `"radio"` with the `checked` property. +- **Displaying**: Use `ContextMenuModel.getInstance().showContextMenu(menu, event)` to render the menu. + +## Common Use Cases + +### File/Folder Operations +Context menus are commonly used for file operations like creating, renaming, and deleting files or folders. + +### Settings Toggles +Use checkbox menu items to toggle settings on and off, with the `checked` property reflecting the current state. + +### Nested Options +Use `submenu` to organize related options hierarchically, keeping the top-level menu clean and organized. + +### Conditional Items +Use the `visible` and `enabled` properties to dynamically show or disable menu items based on the current state. diff --git a/.kilocode/skills/create-view/SKILL.md b/.kilocode/skills/create-view/SKILL.md new file mode 100644 index 000000000..49049ca9e --- /dev/null +++ b/.kilocode/skills/create-view/SKILL.md @@ -0,0 +1,520 @@ +--- +name: create-view +description: Guide for implementing a new view type in Wave Terminal. Use when creating a new view component, implementing the ViewModel interface, registering a new view type in BlockRegistry, or adding a new content type to display within blocks. +--- + +# Creating a New View in Wave Terminal + +This guide explains how to implement a new view type in Wave Terminal. Views are the core content components displayed within blocks in the terminal interface. + +## Architecture Overview + +Wave Terminal uses a **Model-View architecture** where: + +- **ViewModel** - Contains all state, logic, and UI configuration as Jotai atoms +- **ViewComponent** - Pure React component that renders the UI using the model +- **BlockFrame** - Wraps views with a header, connection management, and standard controls + +The separation between model and component ensures: + +- Models can update state without React hooks +- Components remain pure and testable +- State is centralized in Jotai atoms for easy access + +## ViewModel Interface + +Every view must implement the `ViewModel` interface defined in `frontend/types/custom.d.ts`: + +```typescript +interface ViewModel { + // Required: The type identifier for this view (e.g., "term", "web", "preview") + viewType: string; + + // Required: The React component that renders this view + viewComponent: ViewComponent; + + // Optional: Icon shown in block header (FontAwesome icon name or IconButtonDecl) + viewIcon?: jotai.Atom; + + // Optional: Display name shown in block header (e.g., "Terminal", "Web", "Preview") + viewName?: jotai.Atom; + + // Optional: Additional header elements (text, buttons, inputs) shown after the name + viewText?: jotai.Atom; + + // Optional: Icon button shown before the view name in header + preIconButton?: jotai.Atom; + + // Optional: Icon buttons shown at the end of the header (before settings/close) + endIconButtons?: jotai.Atom; + + // Optional: Custom background styling for the block + blockBg?: jotai.Atom; + + // Optional: If true, completely hides the block header + noHeader?: jotai.Atom; + + // Optional: If true, shows connection picker in header for remote connections + manageConnection?: jotai.Atom; + + // Optional: If true, filters out 'nowsh' connections from connection picker + filterOutNowsh?: jotai.Atom; + + // Optional: If true, removes default padding from content area + noPadding?: jotai.Atom; + + // Optional: Atoms for managing in-block search functionality + searchAtoms?: SearchAtoms; + + // Optional: Returns whether this is a basic terminal (for multi-input feature) + isBasicTerm?: (getFn: jotai.Getter) => boolean; + + // Optional: Returns context menu items for the settings dropdown + getSettingsMenuItems?: () => ContextMenuItem[]; + + // Optional: Focuses the view when called, returns true if successful + giveFocus?: () => boolean; + + // Optional: Handles keyboard events, returns true if handled + keyDownHandler?: (e: WaveKeyboardEvent) => boolean; + + // Optional: Cleanup when block is closed + dispose?: () => void; +} +``` + +### Key Concepts + +**Atoms**: All UI-related properties must be Jotai atoms. This enables: + +- Reactive updates when state changes +- Access from anywhere via `globalStore.get()`/`globalStore.set()` +- Derived atoms that compute values from other atoms + +**ViewComponent**: The React component receives these props: + +```typescript +type ViewComponentProps = { + blockId: string; // Unique ID for this block + blockRef: React.RefObject; // Ref to block container + contentRef: React.RefObject; // Ref to content area + model: T; // Your ViewModel instance +}; +``` + +## Step-by-Step Guide + +### 1. Create the View Model Class + +Create a new file for your view model (e.g., `frontend/app/view/myview/myview-model.ts`): + +```typescript +import { BlockNodeModel } from "@/app/block/blocktypes"; +import { globalStore } from "@/app/store/jotaiStore"; +import { WOS, useBlockAtom } from "@/store/global"; +import * as jotai from "jotai"; +import { MyView } from "./myview"; + +export class MyViewModel implements ViewModel { + viewType: string; + blockId: string; + nodeModel: BlockNodeModel; + blockAtom: jotai.Atom; + + // Define your atoms (simple field initializers) + viewIcon = jotai.atom("circle"); + viewName = jotai.atom("My View"); + noPadding = jotai.atom(true); + + // Derived atom (created in constructor) + viewText!: jotai.Atom; + + constructor(blockId: string, nodeModel: BlockNodeModel) { + this.viewType = "myview"; + this.blockId = blockId; + this.nodeModel = nodeModel; + this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); + + // Create derived atoms that depend on block data or other atoms + this.viewText = jotai.atom((get) => { + const blockData = get(this.blockAtom); + const rtn: HeaderElem[] = []; + + // Add header buttons/text based on state + rtn.push({ + elemtype: "iconbutton", + icon: "refresh", + title: "Refresh", + click: () => this.refresh(), + }); + + return rtn; + }); + } + + get viewComponent(): ViewComponent { + return MyView; + } + + refresh() { + // Update state using globalStore + // Never use React hooks in model methods + console.log("refreshing..."); + } + + giveFocus(): boolean { + // Focus your view component + return true; + } + + dispose() { + // Cleanup resources (unsubscribe from events, etc.) + } +} +``` + +### 2. Create the View Component + +Create your React component (e.g., `frontend/app/view/myview/myview.tsx`): + +```typescript +import { ViewComponentProps } from "@/app/block/blocktypes"; +import { MyViewModel } from "./myview-model"; +import { useAtomValue } from "jotai"; +import "./myview.scss"; + +export const MyView: React.FC> = ({ + blockId, + model, + contentRef +}) => { + // Use atoms from the model (these are React hooks - call at top level!) + const blockData = useAtomValue(model.blockAtom); + + return ( +
+
Block ID: {blockId}
+
View: {model.viewType}
+ {/* Your view content here */} +
+ ); +}; +``` + +### 3. Register the View + +Add your view to the `BlockRegistry` in `frontend/app/block/blockregistry.ts`: + +```typescript +import { MyViewModel } from "@/app/view/myview/myview-model"; + +const BlockRegistry: Map = new Map(); +BlockRegistry.set("term", TermViewModel); +BlockRegistry.set("preview", PreviewModel); +BlockRegistry.set("web", WebViewModel); +// ... existing registrations ... +BlockRegistry.set("myview", MyViewModel); // Add your view here +``` + +The registry key (e.g., `"myview"`) becomes the view type used in block metadata. + +### 4. Create Blocks with Your View + +Users can create blocks with your view type: + +- Via CLI: `wsh view myview` +- Via RPC: Use the block's `meta.view` field set to `"myview"` + +## Real-World Examples + +### Example 1: Terminal View (`term-model.ts`) + +The terminal view demonstrates: + +- **Connection management** via `manageConnection` atom +- **Dynamic header buttons** showing shell status (play/restart) +- **Mode switching** between terminal and vdom views +- **Custom keyboard handling** for terminal-specific shortcuts +- **Focus management** to focus the xterm.js instance +- **Shell integration status** showing AI capability indicators + +Key features: + +```typescript +this.manageConnection = jotai.atom((get) => { + const termMode = get(this.termMode); + if (termMode == "vdom") return false; + return true; // Show connection picker for regular terminal mode +}); + +this.endIconButtons = jotai.atom((get) => { + const shellProcStatus = get(this.shellProcStatus); + const buttons: IconButtonDecl[] = []; + + if (shellProcStatus == "running") { + buttons.push({ + elemtype: "iconbutton", + icon: "refresh", + title: "Restart Shell", + click: this.forceRestartController.bind(this), + }); + } + return buttons; +}); +``` + +### Example 2: Web View (`webview.tsx`) + +The web view shows: + +- **Complex header controls** (back/forward/home/URL input) +- **State management** for loading, URL, and navigation +- **Event handling** for webview navigation events +- **Custom styling** with `noPadding` for full-bleed content +- **Media controls** showing play/pause/mute when media is active + +Key features: + +```typescript +this.viewText = jotai.atom((get) => { + const url = get(this.url); + const rtn: HeaderElem[] = []; + + // Navigation buttons + rtn.push({ + elemtype: "iconbutton", + icon: "chevron-left", + click: this.handleBack.bind(this), + disabled: this.shouldDisableBackButton(), + }); + + // URL input with nested controls + rtn.push({ + elemtype: "div", + className: "block-frame-div-url", + children: [ + { + elemtype: "input", + value: url, + onChange: this.handleUrlChange.bind(this), + onKeyDown: this.handleKeyDown.bind(this), + }, + { + elemtype: "iconbutton", + icon: "rotate-right", + click: this.handleRefresh.bind(this), + }, + ], + }); + + return rtn; +}); +``` + +## Header Elements (`HeaderElem`) + +The `viewText` atom can return an array of these element types: + +```typescript +// Icon button +{ + elemtype: "iconbutton", + icon: "refresh", + title: "Tooltip text", + click: () => { /* handler */ }, + disabled?: boolean, + iconColor?: string, + iconSpin?: boolean, + noAction?: boolean, // Shows icon but no click action +} + +// Text element +{ + elemtype: "text", + text: "Display text", + className?: string, + noGrow?: boolean, + ref?: React.RefObject, + onClick?: (e: React.MouseEvent) => void, +} + +// Text button +{ + elemtype: "textbutton", + text: "Button text", + className?: string, + title: "Tooltip", + onClick: (e: React.MouseEvent) => void, +} + +// Input field +{ + elemtype: "input", + value: string, + className?: string, + onChange: (e: React.ChangeEvent) => void, + onKeyDown?: (e: React.KeyboardEvent) => void, + onFocus?: (e: React.FocusEvent) => void, + onBlur?: (e: React.FocusEvent) => void, + ref?: React.RefObject, +} + +// Container with children +{ + elemtype: "div", + className?: string, + children: HeaderElem[], + onMouseOver?: (e: React.MouseEvent) => void, + onMouseOut?: (e: React.MouseEvent) => void, +} + +// Menu button (dropdown) +{ + elemtype: "menubutton", + // ... MenuButtonProps ... +} +``` + +## Best Practices + +### Jotai Model Pattern + +Follow these rules for Jotai atoms in models: + +1. **Simple atoms as field initializers**: + + ```typescript + viewIcon = jotai.atom("circle"); + noPadding = jotai.atom(true); + ``` + +2. **Derived atoms in constructor** (need dependency on other atoms): + + ```typescript + constructor(blockId: string, nodeModel: BlockNodeModel) { + this.viewText = jotai.atom((get) => { + const blockData = get(this.blockAtom); + return [/* computed based on blockData */]; + }); + } + ``` + +3. **Models never use React hooks** - Use `globalStore.get()`/`set()`: + + ```typescript + refresh() { + const currentData = globalStore.get(this.blockAtom); + globalStore.set(this.dataAtom, newData); + } + ``` + +4. **Components use hooks for atoms**: + ```typescript + const data = useAtomValue(model.dataAtom); + const [value, setValue] = useAtom(model.valueAtom); + ``` + +### State Management + +- All view state should live in atoms on the model +- Use `useBlockAtom()` helper for block-scoped atoms that persist +- Use `globalStore` for imperative access outside React components +- Subscribe to Wave events using `waveEventSubscribe()` + +### Styling + +- Create a `.scss` file for your view styles +- Use Tailwind utilities where possible (v4) +- Add `noPadding: atom(true)` for full-bleed content +- Use `blockBg` atom to customize block background + +### Focus Management + +Implement `giveFocus()` to focus your view when: + +- Block gains focus via keyboard navigation +- User clicks the block +- Return `true` if successfully focused, `false` otherwise + +### Keyboard Handling + +Implement `keyDownHandler(e: WaveKeyboardEvent)` for: + +- View-specific keyboard shortcuts +- Return `true` if event was handled (prevents propagation) +- Use `keyutil.checkKeyPressed(waveEvent, "Cmd:K")` for shortcut checks + +### Cleanup + +Implement `dispose()` to: + +- Unsubscribe from Wave events +- Unregister routes/handlers +- Clear timers/intervals +- Release resources + +### Connection Management + +For views that need remote connections: + +```typescript +this.manageConnection = jotai.atom(true); // Show connection picker +this.filterOutNowsh = jotai.atom(true); // Hide nowsh connections +``` + +Access connection status: + +```typescript +const connStatus = jotai.atom((get) => { + const blockData = get(this.blockAtom); + const connName = blockData?.meta?.connection; + return get(getConnStatusAtom(connName)); +}); +``` + +## Common Patterns + +### Reading Block Metadata + +```typescript +import { getBlockMetaKeyAtom } from "@/store/global"; + +// In constructor: +this.someFlag = getBlockMetaKeyAtom(blockId, "myview:flag"); + +// In component: +const flag = useAtomValue(model.someFlag); +``` + +### Configuration Overrides + +Wave has a hierarchical config system (global → connection → block): + +```typescript +import { getOverrideConfigAtom } from "@/store/global"; + +this.settingAtom = jotai.atom((get) => { + // Checks block meta, then connection config, then global settings + return get(getOverrideConfigAtom(this.blockId, "myview:setting")) ?? defaultValue; +}); +``` + +### Updating Block Metadata + +```typescript +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { WOS } from "@/store/global"; + +await RpcApi.SetMetaCommand(TabRpcClient, { + oref: WOS.makeORef("block", this.blockId), + meta: { "myview:key": value }, +}); +``` + +## Additional Resources + +- `frontend/app/block/blockframe-header.tsx` - Block header rendering +- `frontend/app/view/term/term-model.ts` - Complex view example +- `frontend/app/view/webview/webview.tsx` - Navigation UI example +- `frontend/types/custom.d.ts` - Type definitions diff --git a/.kilocode/skills/electron-api/SKILL.md b/.kilocode/skills/electron-api/SKILL.md new file mode 100644 index 000000000..0014e82a5 --- /dev/null +++ b/.kilocode/skills/electron-api/SKILL.md @@ -0,0 +1,182 @@ +--- +name: electron-api +description: Guide for adding new Electron APIs to Wave Terminal. Use when implementing new frontend-to-electron communications via preload/IPC. +--- + +# Adding Electron APIs + +Electron APIs allow the frontend to call Electron main process functionality directly via IPC. + +## Four Files to Edit + +1. [`frontend/types/custom.d.ts`](frontend/types/custom.d.ts) - TypeScript [`ElectronApi`](frontend/types/custom.d.ts:82) type +2. [`emain/preload.ts`](emain/preload.ts) - Expose method via `contextBridge` +3. [`emain/emain-ipc.ts`](emain/emain-ipc.ts) - Implement IPC handler +4. [`frontend/preview/preview-electron-api.ts`](frontend/preview/preview-electron-api.ts) - Add a no-op stub to keep the `previewElectronApi` object in sync with the `ElectronApi` type + +## Three Communication Patterns + +1. **Sync** - `ipcRenderer.sendSync()` + `ipcMain.on()` + `event.returnValue = ...` +2. **Async** - `ipcRenderer.invoke()` + `ipcMain.handle()` +3. **Fire-and-forget** - `ipcRenderer.send()` + `ipcMain.on()` + +## Example: Async Method + +### 1. Define TypeScript Interface + +In [`frontend/types/custom.d.ts`](frontend/types/custom.d.ts): + +```typescript +type ElectronApi = { + captureScreenshot: (rect: Electron.Rectangle) => Promise; // capture-screenshot +}; +``` + +### 2. Expose in Preload + +In [`emain/preload.ts`](emain/preload.ts): + +```typescript +contextBridge.exposeInMainWorld("api", { + captureScreenshot: (rect: Rectangle) => ipcRenderer.invoke("capture-screenshot", rect), +}); +``` + +### 3. Implement Handler + +In [`emain/emain-ipc.ts`](emain/emain-ipc.ts): + +```typescript +electron.ipcMain.handle("capture-screenshot", async (event, rect) => { + const tabView = getWaveTabViewByWebContentsId(event.sender.id); + if (!tabView) throw new Error("No tab view found"); + const image = await tabView.webContents.capturePage(rect); + return `data:image/png;base64,${image.toPNG().toString("base64")}`; +}); +``` + +### 4. Add Preview Stub + +In [`frontend/preview/preview-electron-api.ts`](frontend/preview/preview-electron-api.ts): + +```typescript +captureScreenshot: (_rect: Electron.Rectangle) => Promise.resolve(""), +``` + +### 5. Call from Frontend + +```typescript +import { getApi } from "@/store/global"; + +const dataUrl = await getApi().captureScreenshot({ x: 0, y: 0, width: 800, height: 600 }); +``` + +## Example: Sync Method + +### 1. Define + +```typescript +type ElectronApi = { + getUserName: () => string; // get-user-name +}; +``` + +### 2. Preload + +```typescript +getUserName: () => ipcRenderer.sendSync("get-user-name"), +``` + +### 3. Handler (âš ī¸ MUST set event.returnValue or browser hangs) + +```typescript +electron.ipcMain.on("get-user-name", (event) => { + event.returnValue = process.env.USER || "unknown"; +}); +``` + +### 4. Call + +```typescript +import { getApi } from "@/store/global"; + +const userName = getApi().getUserName(); // blocks until returns +``` + +## Example: Fire-and-Forget + +### 1. Define + +```typescript +type ElectronApi = { + openExternal: (url: string) => void; // open-external +}; +``` + +### 2. Preload + +```typescript +openExternal: (url) => ipcRenderer.send("open-external", url), +``` + +### 3. Handler + +```typescript +electron.ipcMain.on("open-external", (event, url) => { + electron.shell.openExternal(url); +}); +``` + +## Example: Event Listener + +### 1. Define + +```typescript +type ElectronApi = { + onZoomFactorChange: (callback: (zoomFactor: number) => void) => void; // zoom-factor-change +}; +``` + +### 2. Preload + +```typescript +onZoomFactorChange: (callback) => + ipcRenderer.on("zoom-factor-change", (_event, zoomFactor) => callback(zoomFactor)), +``` + +### 3. Send from Main + +```typescript +webContents.send("zoom-factor-change", newZoomFactor); +``` + +## Quick Reference + +**Use Sync when:** +- Getting config/env vars +- Quick lookups, no I/O +- âš ī¸ **CRITICAL**: Always set `event.returnValue` or browser hangs + +**Use Async when:** +- File operations +- Network requests +- Can fail or take time + +**Use Fire-and-forget when:** +- No return value needed +- Triggering actions + +**Electron API vs RPC:** +- Electron API: Native OS features, window management, Electron APIs +- RPC: Database, backend logic, remote servers + +## Checklist + +- [ ] Add to [`ElectronApi`](frontend/types/custom.d.ts:82) in [`custom.d.ts`](frontend/types/custom.d.ts) +- [ ] Include IPC channel name in comment +- [ ] Expose in [`preload.ts`](emain/preload.ts) +- [ ] Implement in [`emain-ipc.ts`](emain/emain-ipc.ts) +- [ ] Add no-op stub to [`preview-electron-api.ts`](frontend/preview/preview-electron-api.ts) +- [ ] IPC channel names match exactly +- [ ] **For sync**: Set `event.returnValue` (or browser hangs!) +- [ ] Test end-to-end diff --git a/.kilocode/skills/waveenv/SKILL.md b/.kilocode/skills/waveenv/SKILL.md new file mode 100644 index 000000000..c5d56af4f --- /dev/null +++ b/.kilocode/skills/waveenv/SKILL.md @@ -0,0 +1,133 @@ +--- +name: waveenv +description: Guide for creating WaveEnv narrowings in Wave Terminal. Use when writing a named subset type of WaveEnv for a component tree, documenting environmental dependencies, or enabling mock environments for preview/test server usage. +--- + +# WaveEnv Narrowing Skill + +## Purpose + +A WaveEnv narrowing creates a _named subset type_ of `WaveEnv` that: + +1. Documents exactly which parts of the environment a component tree actually uses. +2. Forms a type contract so callers and tests know what to provide. +3. Enables mocking in the preview/test server — you only need to implement what's listed. + +## When To Create One + +Create a narrowing whenever you are writing a component (or group of components) that you want to test in the preview server, or when you want to make the environmental dependencies of a component tree explicit. + +## Core Principle: Only Include What You Use + +**Only list the fields, methods, atoms, and keys that the component tree actually accesses.** If you don't call `wos`, don't include `wos`. If you only call one RPC command, only list that one command. The narrowing is a precise dependency declaration — not a copy of `WaveEnv`. + +## File Location + +- **Separate file** (preferred for shared/complex envs): name it `env.ts` next to the component, e.g. `frontend/app/block/blockenv.ts`. +- **Inline** (acceptable for small, single-file components): export the type directly from the component file, e.g. `WidgetsEnv` in `frontend/app/workspace/widgets.tsx`. + +## Imports Required + +```ts +import { + MetaKeyAtomFnType, // only if you use getBlockMetaKeyAtom or getTabMetaKeyAtom + ConnConfigKeyAtomFnType, // only if you use getConnConfigKeyAtom + SettingsKeyAtomFnType, // only if you use getSettingsKeyAtom + WaveEnv, + WaveEnvSubset, +} from "@/app/waveenv/waveenv"; +``` + +## The Shape + +```ts +export type MyEnv = WaveEnvSubset<{ + // --- Simple WaveEnv properties --- + // Copy the type verbatim from WaveEnv with WaveEnv["key"] syntax. + isDev: WaveEnv["isDev"]; + createBlock: WaveEnv["createBlock"]; + showContextMenu: WaveEnv["showContextMenu"]; + platform: WaveEnv["platform"]; + + // --- electron: list only the methods you call --- + electron: { + openExternal: WaveEnv["electron"]["openExternal"]; + }; + + // --- rpc: list only the commands you call --- + rpc: { + ActivityCommand: WaveEnv["rpc"]["ActivityCommand"]; + ConnEnsureCommand: WaveEnv["rpc"]["ConnEnsureCommand"]; + }; + + // --- atoms: list only the atoms you read --- + atoms: { + modalOpen: WaveEnv["atoms"]["modalOpen"]; + fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; + }; + + // --- wos: always take the whole thing, no sub-typing needed --- + wos: WaveEnv["wos"]; + + // --- services: list only the services you call; no method-level narrowing --- + services: { + block: WaveEnv["services"]["block"]; + workspace: WaveEnv["services"]["workspace"]; + }; + + // --- key-parameterized atom factories: enumerate the keys you use --- + getSettingsKeyAtom: SettingsKeyAtomFnType<"app:focusfollowscursor" | "window:magnifiedblockopacity">; + getBlockMetaKeyAtom: MetaKeyAtomFnType<"view" | "frame:title" | "connection">; + getTabMetaKeyAtom: MetaKeyAtomFnType<"tabid" | "name">; + getConnConfigKeyAtom: ConnConfigKeyAtomFnType<"conn:wshenabled">; + + // --- other atom helpers: copy verbatim --- + getConnStatusAtom: WaveEnv["getConnStatusAtom"]; + getLocalHostDisplayNameAtom: WaveEnv["getLocalHostDisplayNameAtom"]; + getConfigBackgroundAtom: WaveEnv["getConfigBackgroundAtom"]; +}>; +``` + +### Automatically Included Fields + +Every `WaveEnvSubset` automatically includes the mock fields — you never need to declare them: + +- `isMock: boolean` +- `mockSetWaveObj: (oref: string, obj: T) => void` +- `mockModels?: Map` + +### Rules for Each Section + +| Section | Pattern | Notes | +| -------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | +| `electron` | `electron: { method: WaveEnv["electron"]["method"]; }` | List every method called; omit the rest. | +| `rpc` | `rpc: { Cmd: WaveEnv["rpc"]["Cmd"]; }` | List every RPC command called; omit the rest. | +| `atoms` | `atoms: { atom: WaveEnv["atoms"]["atom"]; }` | List every atom read; omit the rest. | +| `wos` | `wos: WaveEnv["wos"]` | Take the whole `wos` object (no sub-typing needed), but **only add it if `wos` is actually used**. | +| `services` | `services: { svc: WaveEnv["services"]["svc"]; }` | List each service used; take the whole service object (no method-level narrowing). | +| `getSettingsKeyAtom` | `SettingsKeyAtomFnType<"key1" \| "key2">` | Union all settings keys accessed. | +| `getBlockMetaKeyAtom` | `MetaKeyAtomFnType<"key1" \| "key2">` | Union all block meta keys accessed. | +| `getTabMetaKeyAtom` | `MetaKeyAtomFnType<"key1" \| "key2">` | Union all tab meta keys accessed. | +| `getConnConfigKeyAtom` | `ConnConfigKeyAtomFnType<"key1">` | Union all conn config keys accessed. | +| All other `WaveEnv` fields | `WaveEnv["fieldName"]` | Copy type verbatim. | + +## Using the Narrowed Type in Components + +```ts +import { useWaveEnv } from "@/app/waveenv/waveenv"; +import { MyEnv } from "./myenv"; + +const MyComponent = memo(() => { + const env = useWaveEnv(); + // TypeScript now enforces you only access what's in MyEnv. + const val = useAtomValue(env.getSettingsKeyAtom("app:focusfollowscursor")); + ... +}); +``` + +The generic parameter on `useWaveEnv()` casts the context to your narrowed type. The real production `WaveEnv` satisfies every narrowing; mock envs only need to implement the listed subset. + +## Real Examples + +- `BlockEnv` in `frontend/app/block/blockenv.ts` — complex narrowing with all section types, in a separate file. +- `WidgetsEnv` in `frontend/app/workspace/widgets.tsx` — smaller narrowing defined inline in the component file. diff --git a/.kilocode/skills/wps-events/SKILL.md b/.kilocode/skills/wps-events/SKILL.md new file mode 100644 index 000000000..322fdd4b3 --- /dev/null +++ b/.kilocode/skills/wps-events/SKILL.md @@ -0,0 +1,339 @@ +--- +name: wps-events +description: Guide for working with Wave Terminal's WPS (Wave PubSub) event system. Use when implementing new event types, publishing events, subscribing to events, or adding asynchronous communication between components. +--- + +# WPS Events Guide + +## Overview + +WPS (Wave PubSub) is Wave Terminal's publish-subscribe event system that enables different parts of the application to communicate asynchronously. The system uses a broker pattern to route events from publishers to subscribers based on event types and scopes. + +## Key Files + +- `pkg/wps/wpstypes.go` - Event type constants and data structures +- `pkg/wps/wps.go` - Broker implementation and core logic +- `pkg/wcore/wcore.go` - Example usage patterns + +## Event Structure + +Events in WPS have the following structure: + +```go +type WaveEvent struct { + Event string `json:"event"` // Event type constant + Scopes []string `json:"scopes,omitempty"` // Optional scopes for targeted delivery + Sender string `json:"sender,omitempty"` // Optional sender identifier + Persist int `json:"persist,omitempty"` // Number of events to persist in history + Data any `json:"data,omitempty"` // Event payload +} +``` + +## Adding a New Event Type + +### Step 1: Define the Event Constant + +Add your event type constant to `pkg/wps/wpstypes.go`: + +```go +const ( + Event_BlockClose = "blockclose" + Event_ConnChange = "connchange" + // ... other events ... + Event_YourNewEvent = "your:newevent" // type: YourEventData (or "none" if no data) +) +``` + +**Naming Convention:** + +- Use descriptive PascalCase for the constant name with `Event_` prefix +- Use lowercase with colons for the string value (e.g., "namespace:eventname") +- Group related events with the same namespace prefix +- Always add a `// type: ` comment; use `// type: none` if no data is sent + +### Step 2: Add to AllEvents + +Add your new constant to the `AllEvents` slice in `pkg/wps/wpstypes.go`: + +```go +var AllEvents []string = []string{ + // ... existing events ... + Event_YourNewEvent, +} +``` + +### Step 3: Register in WaveEventDataTypes (REQUIRED) + +You **must** add an entry to `WaveEventDataTypes` in `pkg/tsgen/tsgenevent.go`. This drives TypeScript type generation for the event's `data` field: + +```go +var WaveEventDataTypes = map[string]reflect.Type{ + // ... existing entries ... + wps.Event_YourNewEvent: reflect.TypeOf(YourEventData{}), // value type + // wps.Event_YourNewEvent: reflect.TypeOf((*YourEventData)(nil)), // pointer type + // wps.Event_YourNewEvent: nil, // no data (type: none) +} +``` + +- Use `reflect.TypeOf(YourType{})` for value types +- Use `reflect.TypeOf((*YourType)(nil))` for pointer types +- Use `nil` if no data is sent for the event + +### Step 4: Define Event Data Structure (Optional) + +If your event carries structured data, define a type for it: + +```go +type YourEventData struct { + Field1 string `json:"field1"` + Field2 int `json:"field2"` +} +``` + +### Step 5: Expose Type to Frontend (If Needed) + +If your event data type isn't already exposed via an RPC call, you need to add it to `pkg/tsgen/tsgen.go` so TypeScript types are generated: + +```go +// add extra types to generate here +var ExtraTypes = []any{ + waveobj.ORef{}, + // ... other types ... + uctypes.RateLimitInfo{}, // Example: already added + YourEventData{}, // Add your new type here +} +``` + +Then run code generation: + +```bash +task generate +``` + +This will update `frontend/types/gotypes.d.ts` with TypeScript definitions for your type, ensuring type safety in the frontend when handling these events. + +## Publishing Events + +### Basic Publishing + +To publish an event, use the global broker: + +```go +import "github.com/s-zx/crest/pkg/wps" + +wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_YourNewEvent, + Data: yourData, +}) +``` + +### Publishing with Scopes + +Scopes allow targeted event delivery. Subscribers can filter events by scope: + +```go +wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_WaveObjUpdate, + Scopes: []string{oref.String()}, // Target specific object + Data: updateData, +}) +``` + +### Publishing in a Goroutine + +To avoid blocking the caller, publish events asynchronously: + +```go +go func() { + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_YourNewEvent, + Data: data, + }) +}() +``` + +**When to use goroutines:** + +- When publishing from performance-critical code paths +- When the event is informational and doesn't need immediate delivery +- When publishing from code that holds locks (to prevent deadlocks) + +### Event Persistence + +Events can be persisted in memory for late subscribers: + +```go +wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_YourNewEvent, + Persist: 100, // Keep last 100 events + Data: data, +}) +``` + +## Complete Example: Rate Limit Updates + +This example shows how rate limit information is published when AI chat responses include rate limit headers. + +### 1. Define the Event Type + +In `pkg/wps/wpstypes.go`: + +```go +const ( + // ... other events ... + Event_WaveAIRateLimit = "waveai:ratelimit" +) +``` + +### 2. Publish the Event + +In `pkg/aiusechat/usechat.go`: + +```go +import "github.com/s-zx/crest/pkg/wps" + +func updateRateLimit(info *uctypes.RateLimitInfo) { + if info == nil { + return + } + rateLimitLock.Lock() + defer rateLimitLock.Unlock() + globalRateLimitInfo = info + + // Publish event in goroutine to avoid blocking + go func() { + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_WaveAIRateLimit, + Data: info, // RateLimitInfo struct + }) + }() +} +``` + +### 3. Subscribe to the Event (Frontend) + +In the frontend, subscribe to events via WebSocket: + +```typescript +// Subscribe to rate limit updates +const subscription = { + event: "waveai:ratelimit", + allscopes: true, // Receive all rate limit events +}; +``` + +## Subscribing to Events + +### From Go Code + +```go +// Subscribe to all events of a type +wps.Broker.Subscribe(routeId, wps.SubscriptionRequest{ + Event: wps.Event_YourNewEvent, + AllScopes: true, +}) + +// Subscribe to specific scopes +wps.Broker.Subscribe(routeId, wps.SubscriptionRequest{ + Event: wps.Event_WaveObjUpdate, + Scopes: []string{"workspace:123"}, +}) + +// Unsubscribe +wps.Broker.Unsubscribe(routeId, wps.Event_YourNewEvent) +``` + +### Scope Matching + +Scopes support wildcard matching: + +- `*` matches a single scope segment +- `**` matches multiple scope segments + +```go +// Subscribe to all workspace events +wps.Broker.Subscribe(routeId, wps.SubscriptionRequest{ + Event: wps.Event_WaveObjUpdate, + Scopes: []string{"workspace:*"}, +}) +``` + +## Best Practices + +1. **Use Namespaces**: Prefix event names with a namespace (e.g., `waveai:`, `workspace:`, `block:`) + +2. **Don't Block**: Use goroutines when publishing from performance-critical code or while holding locks + +3. **Type-Safe Data**: Define struct types for event data rather than using maps + +4. **Scope Wisely**: Use scopes to limit event delivery and reduce unnecessary processing + +5. **Document Events**: Add comments explaining when events are fired and what data they carry + +6. **Consider Persistence**: Use `Persist` for events that late subscribers might need (like status updates). This is normally not used. We normally do a live RPC call to get the current value and then subscribe for updates. + +## Common Event Patterns + +### Status Updates + +```go +wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_ControllerStatus, + Scopes: []string{blockId}, + Persist: 1, // Keep only latest status + Data: statusData, +}) +``` + +### Object Updates + +```go +wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_WaveObjUpdate, + Scopes: []string{oref.String()}, + Data: waveobj.WaveObjUpdate{ + UpdateType: waveobj.UpdateType_Update, + OType: obj.GetOType(), + OID: waveobj.GetOID(obj), + Obj: obj, + }, +}) +``` + +### Batch Updates + +```go +// Helper function for multiple updates +func (b *BrokerType) SendUpdateEvents(updates waveobj.UpdatesRtnType) { + for _, update := range updates { + b.Publish(WaveEvent{ + Event: Event_WaveObjUpdate, + Scopes: []string{waveobj.MakeORef(update.OType, update.OID).String()}, + Data: update, + }) + } +} +``` + +## Debugging + +To debug event flow: + +1. Check broker subscription map: `wps.Broker.SubMap` +2. View persisted events: `wps.Broker.ReadEventHistory(eventType, scope, maxItems)` +3. Add logging in publish/subscribe methods +4. Monitor WebSocket traffic in browser dev tools + +## Quick Reference + +When adding a new event: + +- [ ] Add event constant to [`pkg/wps/wpstypes.go`](pkg/wps/wpstypes.go) with a `// type: ` comment (use `none` if no data) +- [ ] Add the constant to `AllEvents` in [`pkg/wps/wpstypes.go`](pkg/wps/wpstypes.go) +- [ ] **REQUIRED**: Add an entry to `WaveEventDataTypes` in [`pkg/tsgen/tsgenevent.go`](pkg/tsgen/tsgenevent.go) — use `nil` for events with no data +- [ ] Define event data structure (if needed) +- [ ] Add data type to `pkg/tsgen/tsgen.go` for frontend use (if not already exposed via RPC) +- [ ] Run `task generate` to update TypeScript types +- [ ] Publish events using `wps.Broker.Publish()` +- [ ] Use goroutines for non-blocking publish when appropriate +- [ ] Subscribe to events in relevant components diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..885ce6337 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +build +bin +.git +frontend/dist +frontend/node_modules +*.min.* +frontend/app/store/services.ts +frontend/types/gotypes.d.ts diff --git a/.roo/rules/overview.md b/.roo/rules/overview.md new file mode 100644 index 000000000..944a4021d --- /dev/null +++ b/.roo/rules/overview.md @@ -0,0 +1,154 @@ +# Wave Terminal - High Level Architecture Overview + +## Project Description + +Wave Terminal is an open-source AI-native terminal built for seamless workflows. It's an Electron application that serves as a command line terminal host (it hosts CLI applications rather than running inside a CLI). The application combines a React frontend with a Go backend server to provide a modern terminal experience with advanced features. + +## Top-Level Directory Structure + +``` +waveterm/ +├── emain/ # Electron main process code +├── frontend/ # React application (renderer process) +├── cmd/ # Go command-line applications +├── pkg/ # Go packages/modules +├── db/ # Database migrations +├── docs/ # Documentation (Docusaurus) +├── build/ # Build configuration and assets +├── assets/ # Application assets (icons, images) +├── public/ # Static public assets +├── tests/ # Test files +├── .github/ # GitHub workflows and configuration +└── Configuration files (package.json, tsconfig.json, etc.) +``` + +## Architecture Components + +### 1. Electron Main Process (`emain/`) + +The Electron main process handles the native desktop application layer: + +**Key Files:** + +- [`emain.ts`](emain/emain.ts) - Main entry point, application lifecycle management +- [`emain-window.ts`](emain/emain-window.ts) - Window management (`WaveBrowserWindow` class) +- [`emain-tabview.ts`](emain/emain-tabview.ts) - Tab view management (`WaveTabView` class) +- [`emain-wavesrv.ts`](emain/emain-wavesrv.ts) - Go backend server integration +- [`emain-wsh.ts`](emain/emain-wsh.ts) - WSH (Wave Shell) client integration +- [`emain-ipc.ts`](emain/emain-ipc.ts) - IPC handlers for frontend ↔ main process communication +- [`emain-menu.ts`](emain/emain-menu.ts) - Application menu system +- [`updater.ts`](emain/updater.ts) - Auto-update functionality +- [`preload.ts`](emain/preload.ts) - Preload script for renderer security +- [`preload-webview.ts`](emain/preload-webview.ts) - Webview preload script + +### 2. Frontend React Application (`frontend/`) + +The React application runs in the Electron renderer process: + +**Structure:** + +``` +frontend/ +├── app/ # Main application code +│ ├── app.tsx # Root App component +│ ├── aipanel/ # AI panel UI +│ ├── block/ # Block-based UI components +│ ├── element/ # Reusable UI elements +│ ├── hook/ # Custom React hooks +│ ├── modals/ # Modal components +│ ├── store/ # State management (Jotai) +│ ├── tab/ # Tab components +│ ├── view/ # Different view types +│ │ ├── codeeditor/ # Code editor (Monaco) +│ │ ├── preview/ # File preview +│ │ ├── sysinfo/ # System info view +│ │ ├── term/ # Terminal view +│ │ ├── tsunami/ # Tsunami builder view +│ │ ├── vdom/ # Virtual DOM view +│ │ ├── waveai/ # AI chat integration +│ │ ├── waveconfig/ # Config editor view +│ │ └── webview/ # Web view +│ └── workspace/ # Workspace management +├── builder/ # Builder app entry +├── layout/ # Layout system +├── preview/ # Standalone preview renderer +├── types/ # TypeScript type definitions +└── util/ # Utility functions +``` + +**Key Technologies:** + +- Electron (desktop application shell) +- React 19 with TypeScript +- Jotai for state management +- Monaco Editor for code editing +- XTerm.js for terminal emulation +- Tailwind CSS v4 for styling +- SCSS for additional styling (deprecated, new components should use Tailwind) +- Vite / electron-vite for bundling +- Task (Taskfile.yml) for build and code generation commands + +### 3. Go Backend Server (`cmd/server/`) + +The Go backend server handles all heavy lifting operations: + +**Entry Point:** [`main-server.go`](cmd/server/main-server.go) + +### 4. Go Packages (`pkg/`) + +The Go codebase is organized into modular packages: + +**Key Packages:** + +- `wstore/` - Database and storage layer +- `wconfig/` - Configuration management +- `wcore/` - Core business logic +- `wshrpc/` - RPC communication system +- `wshutil/` - WSH (Wave Shell) utilities +- `blockcontroller/` - Block execution management +- `remote/` - Remote connection handling +- `filestore/` - File storage system +- `web/` - Web server and WebSocket handling +- `telemetry/` - Usage analytics and telemetry +- `waveobj/` - Core data objects +- `service/` - Service layer +- `wps/` - Wave PubSub event system +- `waveai/` - AI functionality +- `shellexec/` - Shell execution +- `util/` - Common utilities + +### 5. Command Line Tools (`cmd/`) + +Key Go command-line utilities: + +- `wsh/` - Wave Shell command-line tool +- `server/` - Main backend server +- `generatego/` - Code generation +- `generateschema/` - Schema generation +- `generatets/` - TypeScript generation + +## Communication Architecture + +The core communication system is built around the **WSH RPC (Wave Shell RPC)** system, which provides a unified interface for all inter-process communication: frontend ↔ Go backend, Electron main process ↔ backend, and backend ↔ remote systems (SSH, WSL). + +### WSH RPC System (`pkg/wshrpc/`) + +The WSH RPC system is the backbone of Wave Terminal's communication architecture: + +**Key Components:** + +- [`wshrpctypes.go`](pkg/wshrpc/wshrpctypes.go) - Core RPC interface and type definitions (source of truth for all RPC commands) +- [`wshserver/`](pkg/wshrpc/wshserver/) - Server-side RPC implementation +- [`wshremote/`](pkg/wshrpc/wshremote/) - Remote connection handling +- [`wshclient.go`](pkg/wshrpc/wshclient.go) - Go client for making RPC calls +- [`frontend/app/store/wshclientapi.ts`](frontend/app/store/wshclientapi.ts) - Generated TypeScript RPC client + +**Routing:** Callers address RPC calls using _routes_ (e.g. a block ID, connection name, or `"waveapp"`) rather than caring about the underlying transport. The RPC layer resolves the route to the correct transport (WebSocket, Unix socket, SSH tunnel, stdio) automatically. This means the same RPC interface works whether the target is local or a remote SSH connection. + +## Development Notes + +- **Build commands** - Use `task` (Taskfile.yml) for all build, generate, and packaging commands +- **Code generation** - Run `task generate` after modifying Go types in `pkg/wshrpc/wshrpctypes.go`, `pkg/wconfig/settingsconfig.go`, or `pkg/waveobj/wtypemeta.go` +- **Testing** - Vitest for frontend unit tests; standard `go test` for Go packages +- **Database migrations** - SQL migration files in `db/migrations-wstore/` and `db/migrations-filestore/` +- **Documentation** - Docusaurus site in `docs/` diff --git a/.roo/rules/rules.md b/.roo/rules/rules.md new file mode 100644 index 000000000..99f3f08b7 --- /dev/null +++ b/.roo/rules/rules.md @@ -0,0 +1,203 @@ +Wave Terminal is a modern terminal which provides graphical blocks, dynamic layout, workspaces, and SSH connection management. It is cross platform and built on electron. + +### Project Structure + +It has a TypeScript/React frontend and a Go backend. They talk together over `wshrpc` a custom RPC protocol that is implemented over websocket (and domain sockets). + +### Coding Guidelines + +- **Go Conventions**: + - Don't use custom enum types in Go. Instead, use string constants (e.g., `const StatusRunning = "running"` rather than creating a custom type like `type Status string`). + - Use string constants for status values, packet types, and other string-based enumerations. + - in Go code, prefer using Printf() vs Println() + - use "Make" as opposed to "New" for struct initialization func names + - in general const decls go at the top of the file (before types and functions) + - NEVER run `go build` (especially in weird sub-package directories). we can tell if everything compiles by seeing there are no problems/errors. +- **Synchronization**: + - Always prefer to use the `lock.Lock(); defer lock.Unlock()` pattern for synchronization if possible + - Avoid inline lock/unlock pairs - instead create helper functions that use the defer pattern + - When accessing shared data structures (maps, slices, etc.), ensure proper locking + - Example: Instead of `gc.lock.Lock(); gc.map[key]++; gc.lock.Unlock()`, create a helper function like `getNextValue(key string) int { gc.lock.Lock(); defer gc.lock.Unlock(); gc.map[key]++; return gc.map[key] }` +- **TypeScript Imports**: + - Use `@/...` for imports from different parts of the project (configured in `tsconfig.json` as `"@/*": ["frontend/*"]`). + - Prefer relative imports (`"./name"`) only within the same directory. + - Use named exports exclusively; avoid default exports. It's acceptable to export functions directly (e.g., React Components). + - Our indent is 4 spaces +- **JSON Field Naming**: All fields must be lowercase, without underscores. +- **TypeScript Conventions** + - **Type Handling**: + - In TypeScript we have strict null checks off, so no need to add "| null" to all the types. + - In TypeScript for Jotai atoms, if we want to write, we need to type the atom as a PrimitiveAtom + - Jotai has a bug with strict null checks off where if you create a null atom, e.g. atom(null) it does not "type" correctly. That's no issue, just cast it to the proper PrimitiveAtom type (no "| null") and it will work fine. + - Generally never use "=== undefined" or "!== undefined". This is bad style. Just use a "== null" or "!= null" unless it is a very specific case where we need to distinguish undefined from null. + - **Coding Style**: + - Use all lowercase filenames (except where case is actually important like Taskfile.yml) + - Import the "cn" function from "@/util/util" to do classname / clsx class merge (it uses twMerge underneath) + - Do NOT create private fields in classes (they are impossible to inspect) + - Use PascalCase for global consts at the top of files + - **Component Practices**: + - Make sure to add cursor-pointer to buttons/links and clickable items + - NEVER use cursor-help (it looks terrible) + - useAtom() and useAtomValue() are react HOOKS, so they must be called at the component level not inline in JSX + - If you use React.memo(), make sure to add a displayName for the component + - Other + - never use atob() or btoa() (not UTF-8 safe). use functions in frontend/util/util.ts for base64 decoding and encoding +- In general, when writing functions, we prefer _early returns_ rather than putting the majority of a function inside of an if block. + +### Styling + +- We use **Tailwind v4** to style. Custom stuff is defined in frontend/tailwindsetup.css +- _never_ use cursor-help, or cursor-not-allowed (it looks terrible) +- We have custom CSS setup as well, so it is a hybrid system. For new code we prefer tailwind, and are working to migrate code to all use tailwind. +- For accent buttons, use "bg-accent/80 text-primary rounded hover:bg-accent transition-colors cursor-pointer" (if you do "bg-accent hover:bg-accent/80" it looks weird as on hover the button gets darker instead of lighter) + +### RPC System + +To define a new RPC call, add the new definition to `pkg/wshrpc/wshrpctypes.go` including any input/output data that is required. After modifying wshrpctypes.go run `task generate` to generate the client APIs. + +For normal "server" RPCs (where a frontend client is calling the main server) you should implement the RPC call in `pkg/wshrpc/wshserver.go`. + +### Electron API + +From within the FE to get the electron API (e.g. the preload functions): + +```ts +import { getApi } from "@/store/global"; + +getApi().getIsDev(); +``` + +The full API is defined in custom.d.ts as type ElectronApi. + +### Code Generation + +- **TypeScript Types**: TypeScript types are automatically generated from Go types. After modifying Go types in `pkg/wshrpc/wshrpctypes.go`, run `task generate` to update the TypeScript type definitions in `frontend/types/gotypes.d.ts`. +- **Manual Edits**: Do not manually edit generated files like `frontend/types/gotypes.d.ts` or `frontend/app/store/wshclientapi.ts`. Instead, modify the source Go types and run `task generate`. + +### Frontend Architecture + +- The application uses Jotai for state management. +- When working with Jotai atoms that need to be updated, define them as `PrimitiveAtom` rather than just `atom`. + +### Notes + +- **CRITICAL: Completion format MUST be: "Done: [one-line description]"** +- **Keep your Task Completed summaries VERY short** +- **No double-summarization** - Put your summary ONLY inside attempt_completion. Do not write a summary in the message body AND then repeat it in attempt_completion. One summary, one place. +- **Go directly to completion** - After making changes, proceed directly to attempt_completion without summarizing +- The project is currently an un-released POC / MVP. Do not worry about backward compatibility when making changes +- With React hooks, always complete all hook calls at the top level before any conditional returns (including jotai hook calls useAtom and useAtomValue); when a user explicitly tells you a function handles null inputs, trust them and stop trying to "protect" it with unnecessary checks or workarounds. +- **Match response length to question complexity** - For simple, direct questions in Ask mode (especially those that can be answered in 1-2 sentences), provide equally brief answers. Save detailed explanations for complex topics or when explicitly requested. +- **CRITICAL** - useAtomValue and useAtom are React HOOKS. They cannot be used inline in JSX code, they must appear at the top of a component in the hooks area of the react code. +- for simple functions, we prefer `if (!cond) { return }; functionality;` pattern over `if (cond) { functionality }` because it produces less indentation and is easier to follow. +- It is now 2026, so if you write new files, or update files use 2026 for the copyright year +- React.MutableRefObject is deprecated, just use React.RefObject now (in React 19 RefObject is always mutable) + +### Strict Comment Rules + +- **NEVER add comments that merely describe what code is doing**: + - ❌ `mutex.Lock() // Lock the mutex` + - ❌ `counter++ // Increment the counter` + - ❌ `buffer.Write(data) // Write data to buffer` + - ❌ `// Header component for app run list` (above AppRunListHeader) + - ❌ `// Updated function to include onClick parameter` + - ❌ `// Changed padding calculation` + - ❌ `// Removed unnecessary div` + - ❌ `// Using the model's width value here` +- **Only use comments for**: + - Explaining WHY a particular approach was chosen + - Documenting non-obvious edge cases or side effects + - Warning about potential pitfalls in usage + - Explaining complex algorithms that can't be simplified +- **When in doubt, leave it out**. No comment is better than a redundant comment. +- **Never add comments explaining code changes** - The code should speak for itself, and version control tracks changes. The one exception to this rule is if it is a very unobvious implementation. Something that someone would typically implement in a different (wrong) way. Then the comment helps us remember WHY we changed it to a less obvious implementation. +- **Never remove existing comments** unless specifically directed by the user. Comments that are already defined in existing code have been vetted by the user. + +### Jotai Model Pattern (our rules) + +- **Atoms live on the model.** +- **Simple atoms:** define as **field initializers**. +- **Atoms that depend on values/other atoms:** create in the **constructor**. +- Models **never use React hooks**; they use `globalStore.get/set`. +- It's fine to call model methods from **event handlers** or **`useEffect`**. +- Models use the **singleton pattern** with a `private static instance` field, a `private constructor`, and a `static getInstance()` method. +- The constructor is `private`; callers always use `getInstance()`. + +```ts +// model/MyModel.ts +import * as jotai from "jotai"; +import { globalStore } from "@/app/store/jotaiStore"; + +export class MyModel { + private static instance: MyModel | null = null; + + // simple atoms (field init) + statusAtom = jotai.atom<"idle" | "running" | "error">("idle"); + outputAtom = jotai.atom(""); + + // ctor-built atoms (need types) + lengthAtom!: jotai.Atom; + thresholdedAtom!: jotai.Atom; + + private constructor(initialThreshold = 20) { + this.lengthAtom = jotai.atom((get) => get(this.outputAtom).length); + this.thresholdedAtom = jotai.atom((get) => get(this.lengthAtom) > initialThreshold); + } + + static getInstance(): MyModel { + if (!MyModel.instance) { + MyModel.instance = new MyModel(); + } + return MyModel.instance; + } + + static resetInstance(): void { + MyModel.instance = null; + } + + async doWork() { + globalStore.set(this.statusAtom, "running"); + // ... do work ... + globalStore.set(this.statusAtom, "idle"); + } +} +``` + +```tsx +// component usage (events & effects OK) +import { useAtomValue } from "jotai"; + +function Panel() { + const model = MyModel.getInstance(); + const status = useAtomValue(model.statusAtom); + const isBig = useAtomValue(model.thresholdedAtom); + + const onClick = () => model.doWork(); + + return ( +
+ {status} â€ĸ {String(isBig)} +
+ ); +} +``` + +**Remember:** singleton pattern with `getInstance()`, `private constructor`, atoms on the model, simple-as-fields, ctor for dependent/derived, updates via `globalStore.set/get`. +**Note** Older models may not use the singleton pattern + +### Tool Use + +Do NOT use write_to_file unless it is a new file or very short. Always prefer to use replace_in_file. Often your diffs fail when a file may be out of date in your cache vs the actual on-disk format. You should RE-READ the file and try to create diffs again if your diffs fail rather than fall back to write_to_file. If you feel like your ONLY option is to use write_to_file please ask first. + +Also when adding content to the end of files prefer to use the new append_file tool rather than trying to create a diff (as your diffs are often not specific enough and end up inserting code in the middle of existing functions). + +### Directory Awareness + +- **ALWAYS verify the current working directory before executing commands** +- Either run "pwd" first to verify the directory, or do a "cd" to the correct absolute directory before running commands +- When running tests, do not "cd" to the pkg directory and then run the test. This screws up the cwd and you never recover. run the test from the project root instead. + +### Testing / Compiling Go Code + +No need to run a `go build` or a `go run` to just check if the Go code compiles. VSCode's errors/problems cover this well. +If there are no Go errors in VSCode you can assume the code compiles fine. diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..23561f94b --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "esbenp.prettier-vscode", + "golang.go", + "dbaeumer.vscode-eslint", + "vitest.explorer", + "task.vscode-task" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..e0209de61 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,68 @@ +{ + "editor.formatOnSave": true, + "editor.detectIndentation": false, + "editor.formatOnPaste": true, + "editor.tabSize": 4, + "editor.insertSpaces": false, + "prettier.useEditorConfig": true, + "diffEditor.renderSideBySide": false, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[less]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[scss]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[css]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[html]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[yaml]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.insertSpaces": true, + "editor.autoIndent": "keep" + }, + "[github-actions-workflow]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.insertSpaces": true, + "editor.autoIndent": "keep" + }, + "[go]": { + "editor.defaultFormatter": "golang.go" + }, + "[mdx]": { + "editor.wordWrap": "on" + }, + "[md]": { + "editor.wordWrap": "on" + }, + "files.associations": { + "*.css": "tailwindcss" + }, + "gopls": { + "analyses": { + "QF1003": false + }, + "directoryFilters": ["-tsunami/frontend/scaffold", "-dist", "-make"] + }, + "tailwindCSS.lint.suggestCanonicalClasses": "ignore", + "go.coverageDecorator": { + "type": "gutter" + } +} diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 000000000..c5332294f --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,64 @@ +{ + "format_on_save": "on", + "languages": { + "JavaScript": { + "formatter": { + "external": { + "command": "./node_modules/.bin/prettier", + "arguments": ["--stdin-filepath", "{buffer_path}"] + } + } + }, + "JSON": { + "formatter": { + "external": { + "command": "./node_modules/.bin/prettier", + "arguments": ["--stdin-filepath", "{buffer_path}"] + } + } + }, + "TypeScript": { + "formatter": { + "external": { + "command": "./node_modules/.bin/prettier", + "arguments": ["--stdin-filepath", "{buffer_path}"] + } + } + }, + "CSS": { + "formatter": { + "external": { + "command": "./node_modules/.bin/prettier", + "arguments": ["--stdin-filepath", "{buffer_path}"] + } + } + }, + "SCSS": { + "formatter": { + "external": { + "command": "./node_modules/.bin/prettier", + "arguments": ["--stdin-filepath", "{buffer_path}"] + } + } + }, + "YAML": { + "formatter": { + "external": { + "command": "./node_modules/.bin/prettier", + "arguments": ["--stdin-filepath", "{buffer_path}"] + } + } + } + }, + "lsp": { + "eslint": { + "settings": { + "codeActionOnSave": { + "rules": ["import/order"] + }, + "nodePath": "./node_modules/.bin", + "language_ids": ["typescript", "javascript", "typescriptreact", "javascriptreact"] + } + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..a50b8ba69 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,32 @@ +@.kilocode/rules/rules.md + +--- + +## Skill Guides + +This project uses a set of "skill" guides — focused how-to documents for common implementation tasks. When your task matches one of the descriptions below, **read the linked SKILL.md file before proceeding** and follow its instructions precisely. + +| Skill | File | Description | +| ------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| add-config | `.kilocode/skills/add-config/SKILL.md` | Guide for adding new configuration settings to Wave Terminal. Use when adding a new setting to the configuration system, implementing a new config key, or adding user-customizable settings. | +| add-rpc | `.kilocode/skills/add-rpc/SKILL.md` | Guide for adding new RPC calls to Wave Terminal. Use when implementing new RPC commands, adding server-client communication methods, or extending the RPC interface with new functionality. | +| add-wshcmd | `.kilocode/skills/add-wshcmd/SKILL.md` | Guide for adding new wsh commands to Wave Terminal. Use when implementing new CLI commands, adding command-line functionality, or extending the wsh command interface. | +| context-menu | `.kilocode/skills/context-menu/SKILL.md` | Guide for creating and displaying context menus in Wave Terminal. Use when implementing right-click menus, adding context menu items, creating submenus, or handling menu interactions with checkboxes and separators. | +| create-view | `.kilocode/skills/create-view/SKILL.md` | Guide for implementing a new view type in Wave Terminal. Use when creating a new view component, implementing the ViewModel interface, registering a new view type in BlockRegistry, or adding a new content type to display within blocks. | +| electron-api | `.kilocode/skills/electron-api/SKILL.md` | Guide for adding new Electron APIs to Wave Terminal. Use when implementing new frontend-to-electron communications via preload/IPC. | +| waveenv | `.kilocode/skills/waveenv/SKILL.md` | Guide for creating WaveEnv narrowings in Wave Terminal. Use when writing a named subset type of WaveEnv for a component tree, documenting environmental dependencies, or enabling mock environments for preview/test server usage. | +| wps-events | `.kilocode/skills/wps-events/SKILL.md` | Guide for working with Wave Terminal's WPS (Wave PubSub) event system. Use when implementing new event types, publishing events, subscribing to events, or adding asynchronous communication between components. | + +--- + +## edgeFlow.js Integration Feedback Loop + +crest's NLD module (`frontend/app/term/nld/`) depends on **edgeFlow.js**, a sibling repo cloned at `../edgeFlow.js`. Whenever you hit friction while integrating or using edgeFlow.js — a missing API, a workaround you had to write, a confusing error, a bundler quirk, a tokenizer / wasm-path issue — log it. + +**Where:** `../edgeFlow.js/docs/INTEGRATION_LOG.md` (read the "How to use this log" section there for the entry template and conventions). + +**When:** the moment you write a workaround. Don't batch. + +**On the consumer side:** mark every workaround with `// TODO(edgeflow): docs/INTEGRATION_LOG.md YYYY-MM-DD — ` so future readers can trace why the code looks the way it does, and so the workaround surfaces in `grep` once edgeFlow.js catches up. + +This loop is the only mechanism we have to drive concrete library improvements rather than letting workarounds rot silently in crest. Treat it as load-bearing. diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..ded9695d5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2025 Command Line Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 000000000..ba7f13e36 --- /dev/null +++ b/NOTICE @@ -0,0 +1,30 @@ +Crest +Copyright 2025-2026 Command Line Inc. and contributors. + +This product includes software developed by third parties under the +Apache License, Version 2.0. See per-subpackage NOTICE files for the +full list of derivative works: + + - pkg/agent/NOTICE + Agent-mode system prompts and structural patterns derived from + ForgeCode (https://github.com/tailcallhq/forgecode), Apache + License 2.0. + +This product also includes software derived from third parties under +the MIT License. The relevant sources retain their original copyright +notices in their file headers: + + - emain/agent/ (started from pi-agent-core v0.75.5) + - emain/ai/ (started from pi-ai v0.75.5) + Copyright (c) Mario Zechner / earendil-works. + Source: https://github.com/earendil-works/pi + Subsequently maintained in-tree as part of crest; see + emain/agent/LICENSE.pi for the MIT terms. + +You may obtain a copy of the Apache License, Version 2.0 at: + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. diff --git a/NOTICES.md b/NOTICES.md new file mode 100644 index 000000000..726025933 --- /dev/null +++ b/NOTICES.md @@ -0,0 +1,77 @@ +# Third-party notices + +This project contains code whose structure and design are derived from +open-source projects. Per each project's license terms, the required +copyright notices and license texts are reproduced below. + +--- + +## Warp Terminal + +Several agent-related modules in this project port data shapes, +interface contracts, behavioral semantics, and UX patterns from +[Warp Terminal](https://github.com/warpdotdev/Warp). The Go and +TypeScript code is original implementation in crest's idioms (Go vs +Rust, React + Tailwind + jotai vs warp's `warpui` GPU framework); the +Warp source informed the structural design only. + +Each derived file carries a top-of-file attribution header pointing at +the specific Warp source path it references. The canonical inventory +is at the end of this section. + +Warp is distributed under the MIT License. Required copyright notice +and license text: + +> Copyright (C) 2020-2026 Denver Technologies, Inc. +> +> Permission is hereby granted, free of charge, to any person obtaining +> a copy of this software and associated documentation files (the +> "Software"), to deal in the Software without restriction, including +> without limitation the rights to use, copy, modify, merge, publish, +> distribute, sublicense, and/or sell copies of the Software, and to +> permit persons to whom the Software is furnished to do so, subject to +> the following conditions: +> +> The above copyright notice and this permission notice shall be +> included in all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +> EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +> MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +> IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +> CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +> TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +> SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +### Files in crest derived from Warp + +| Crest file | Warp source reference | +|---|---| +| `pkg/aiusechat/uctypes/uctypes.go` (Citation, AskUserQuestion\* types) | `crates/ai/src/agent/citation.rs`, `crates/ai/src/agent/action/mod.rs:611-657` | +| `pkg/agent/tools/ask_user_question.go` | `crates/ai/src/agent/action/mod.rs:167-169, 611-657` | +| `pkg/agent/tools/long_running_read.go` | `crates/ai/src/agent/action/mod.rs:126-129, 756-760` | +| `pkg/agent/tools/long_running_write.go` | `crates/ai/src/agent/action/mod.rs:61-65, 762-812` (AIAgentPtyWriteMode + decorate_bytes) | +| `pkg/agent/tools/transfer_to_user.go` | `crates/ai/src/agent/action/mod.rs:161-165` | +| `frontend/app/term/render/agent-block-element.tsx` | `app/src/ai/blocklist/agent_view/agent_view_block.rs`, `app/src/ai/blocklist/agent_view/inline_agent_view_header.rs` | +| `frontend/app/term/render/agent-chat-host.tsx` | `app/src/ai/blocklist/controller/response_stream.rs:45-117`, `app/src/ai/blocklist/history_model.rs:2177-2203` | +| `frontend/app/term/render/citation-chips.tsx` | `app/src/ai/blocklist/inline_action/requested_command_attribution.rs`, `app/src/ai/blocklist/block/view_impl.rs:655-728` | +| `frontend/app/term/render/tool-use-card.tsx` | `app/src/ai/blocklist/inline_action/inline_action_header.rs`, `requested_command.rs`, `code_diff_view.rs`, `app/src/ai/blocklist/block/view_impl.rs:78` (ACCEPT_PROMPT_SUGGESTION_KEYBINDING) | +| `frontend/app/term/render/tool-action-header.tsx` | `app/src/ai/blocklist/inline_action/inline_action_header.rs` | +| `frontend/app/term/render/tool-command-card.tsx` | `app/src/ai/blocklist/inline_action/requested_command.rs` | +| `frontend/app/term/render/tool-diff-card.tsx` | `app/src/ai/blocklist/inline_action/code_diff_view.rs` | +| `frontend/app/term/render/tool-ask-card.tsx` | `app/src/ai/blocklist/inline_action/ask_user_question_view.rs` (UX patterns: ← → nav, ^A cancel, completed-state render); `crates/ai/src/agent/action/mod.rs:611-657` (data shape) | + +Files in `frontend/app/view/cmdblock/` also contain inline +`// warp ` reference comments at specific design decisions; the +underlying component layout there is a separate authoring effort and +not listed individually here. + +### Audit status + +Structural correspondence between crest's port and Warp's source was +audited on 2026-05-20. Strict-port fixes were applied where the crest +implementation deviated from Warp's published shapes (see `A1`–`A4` +in `docs/warp-agent-improvement-plan.md` → "Audit findings"). A short +list of deliberate deviations (crest extensions, stack-driven +divergences) is recorded in the same document under "C-class +decisions". diff --git a/README.md b/README.md new file mode 100644 index 000000000..a9f406725 --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +

+ + + + + Wave Terminal Logo + + +
+

+ +# Wave Terminal + +
+ +[English](README.md) | [한ęĩ­ė–´](README.ko.md) | [įšéĢ”ä¸­æ–‡](README.zh-TW.md) + +
+ +[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm?ref=badge_shield) + +Wave is an open-source, AI-integrated terminal for macOS, Linux, and Windows. It works with any AI model. Bring your own API keys for OpenAI, Claude, or Gemini, or run local models via Ollama and LM Studio. No accounts required. + +Wave also supports durable SSH sessions that survive network interruptions and restarts, with automatic reconnection. Edit remote files with a built-in graphical editor and preview files inline without leaving the terminal. + +![WaveTerm Screenshot](./assets/wave-screenshot.webp) + +## Key Features + +- Wave AI - Context-aware terminal assistant that reads your terminal output, analyzes widgets, and performs file operations +- Durable SSH Sessions - Remote terminal sessions survive connection interruptions, network changes, and Wave restarts with automatic reconnection +- Flexible drag & drop interface to organize terminal blocks, editors, web browsers, and AI assistants +- Built-in editor for editing remote files with syntax highlighting and modern editor features +- Rich file preview system for remote files (markdown, images, video, PDFs, CSVs, directories) +- Quick full-screen toggle for any block - expand terminals, editors, and previews for better visibility, then instantly return to multi-block view +- AI chat widget with support for multiple models (OpenAI, Claude, Azure, Perplexity, Ollama) +- Command Blocks for isolating and monitoring individual commands +- One-click remote connections with full terminal and file system access +- Secure secret storage using native system backends - store API keys and credentials locally, access them across SSH sessions +- Rich customization including tab themes, terminal styles, and background images +- Powerful `wsh` command system for managing your workspace from the CLI and sharing data between terminal sessions +- Connected file management with `wsh file` - seamlessly copy and sync files between local and remote SSH hosts + +## Wave AI + +Wave AI is your context-aware terminal assistant with access to your workspace: + +- **Terminal Context**: Reads terminal output and scrollback for debugging and analysis +- **File Operations**: Read, write, and edit files with automatic backups and user approval +- **CLI Integration**: Use `wsh ai` to pipe output or attach files directly from the command line +- **BYOK Support**: Bring your own API keys for OpenAI, Claude, Gemini, Azure, and other providers +- **Local Models**: Run local models with Ollama, LM Studio, and other OpenAI-compatible providers +- **Free Beta**: Included AI credits while we refine the experience +- **Coming Soon**: Command execution (with approval) + +Learn more in our [Wave AI documentation](https://docs.waveterm.dev/waveai) and [Wave AI Modes documentation](https://docs.waveterm.dev/waveai-modes). + +## Installation + +Wave Terminal works on macOS, Linux, and Windows. + +Platform-specific installation instructions can be found [here](https://docs.waveterm.dev/gettingstarted). + +You can also install Wave Terminal directly from: [www.waveterm.dev/download](https://www.waveterm.dev/download). + +### Minimum requirements + +Wave Terminal runs on the following platforms: + +- macOS 11 or later (arm64, x64) +- Windows 10 1809 or later (x64) +- Linux based on glibc-2.28 or later (Debian 10, RHEL 8, Ubuntu 20.04, etc.) (arm64, x64) + +The WSH helper runs on the following platforms: + +- macOS 11 or later (arm64, x64) +- Windows 10 or later (x64) +- Linux Kernel 2.6.32 or later (x64), Linux Kernel 3.1 or later (arm64) + +## Roadmap + +Wave is constantly improving! Our roadmap will be continuously updated with our goals for each release. You can find it [here](./ROADMAP.md). + +Want to provide input to our future releases? Connect with us on [Discord](https://discord.gg/XfvZ334gwU) or open a [Feature Request](https://github.com/wavetermdev/waveterm/issues/new/choose)! + +## Links + +- Homepage — https://www.waveterm.dev +- Download Page — https://www.waveterm.dev/download +- Documentation — https://docs.waveterm.dev +- X — https://x.com/wavetermdev +- Discord Community — https://discord.gg/XfvZ334gwU + +## Building from Source + +See [Building Wave Terminal](BUILD.md). + +## Contributing + +Wave uses GitHub Issues for issue tracking. + +Find more information in our [Contributions Guide](CONTRIBUTING.md), which includes: + +- [Ways to contribute](CONTRIBUTING.md#contributing-to-wave-terminal) +- [Contribution guidelines](CONTRIBUTING.md#before-you-start) + +### Sponsoring Wave â¤ī¸ + +If Wave Terminal is useful to you or your company, consider sponsoring development. + +Sponsorship helps support the time spent building and maintaining the project. + +- https://github.com/sponsors/wavetermdev + +## License + +Wave Terminal is licensed under the Apache-2.0 License. For more information on our dependencies, see [here](./ACKNOWLEDGEMENTS.md). diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 000000000..4306b4063 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,623 @@ +# Copyright 2026, Command Line Inc. +# SPDX-License-Identifier: Apache-2.0 + +version: "3" + +vars: + APP_NAME: "Wave" + BIN_DIR: "bin" + VERSION: + sh: node version.cjs + RMRF: '{{if eq OS "windows"}}powershell -NoProfile -NonInteractive Remove-Item -Force -Recurse -ErrorAction SilentlyContinue{{else}}rm -rf{{end}}' + DATE: '{{if eq OS "windows"}}powershell -NoProfile -NonInteractive Get-Date -UFormat{{else}}date{{end}}' + ARTIFACTS_BUCKET: waveterm-github-artifacts/staging-w2 + RELEASES_BUCKET: dl.waveterm.dev/releases-w2 + WINGET_PACKAGE: CommandLine.Wave + +tasks: + electron:dev: + desc: Run the Electron application via the Vite dev server (enables hot reloading). + cmd: npm run dev + aliases: + - dev + deps: + - npm:install + - build:backend + - build:tsunamiscaffold + env: + WAVETERM_ENVFILE: "{{.ROOT_DIR}}/.env" + WAVETERM_NOCONFIRMQUIT: "1" + + electron:start: + desc: Run the Electron application directly. + cmd: npm run start + aliases: + - start + deps: + - npm:install + - build:backend + env: + WAVETERM_ENVFILE: "{{.ROOT_DIR}}/.env" + + electron:quickdev: + desc: Run the Electron application via the Vite dev server (quick dev - no docsite, arm64 only, no generate, no wsh). + cmd: npm run dev + deps: + - npm:install + - build:backend:quickdev + env: + WAVETERM_ENVFILE: "{{.ROOT_DIR}}/.env" + WAVETERM_NOCONFIRMQUIT: "1" + + preview: + desc: Run the standalone component preview server with HMR (no Electron, no backend). + dir: frontend/preview + cmd: npx vite + deps: + - npm:install + + build:preview: + desc: Build the component preview server for static deployment. + dir: frontend/preview + cmd: npx vite build + deps: + - npm:install + + electron:winquickdev: + desc: Run the Electron application via the Vite dev server (quick dev - Windows amd64 only, no generate, no wsh). + cmd: npm run dev + deps: + - npm:install + - build:backend:quickdev:windows + env: + WAVETERM_ENVFILE: "{{.ROOT_DIR}}/.env" + + package: + desc: Package the application for the current platform. + cmds: + - npm run build:prod && npm exec electron-builder -- -c electron-builder.config.cjs -p never {{.CLI_ARGS}} + deps: + - clean + - npm:install + - build:backend + - build:tsunamiscaffold + + build:frontend:dev: + desc: Build the frontend in development mode. + cmd: npm run build:dev + deps: + - npm:install + + build:backend: + desc: Build the wavesrv and wsh components. + cmds: + - task: build:server + - task: build:wsh + + build:backend:quickdev: + desc: Build only the wavesrv component for quickdev (arm64 macOS only, no generate, no wsh). + cmds: + - task: build:server:quickdev + sources: + - go.mod + - go.sum + - pkg/**/*.go + - pkg/**/*.sh + - cmd/**/*.go + - tsunami/go.mod + - tsunami/go.sum + - tsunami/**/*.go + - package.json + + build:schema: + desc: Build the schema for configuration. + sources: + - "cmd/generateschema/*.go" + - "pkg/wconfig/*.go" + generates: + - "dist/schema/**/*" + cmds: + - go run cmd/generateschema/main-generateschema.go + - cmd: '{{.RMRF}} "dist/schema"' + ignore_error: true + - task: copyfiles:'schema':'dist/schema' + + build:server: + desc: Build the wavesrv component. + cmds: + - task: build:server:linux + - task: build:server:macos + - task: build:server:windows + deps: + - go:mod:tidy + - generate + sources: + - "cmd/server/*.go" + - "pkg/**/*.go" + - "pkg/**/*.json" + - "pkg/**/*.sh" + - tsunami/**/*.go + - package.json + generates: + - dist/bin/wavesrv.* + + build:server:macos: + desc: Build the wavesrv component for macOS (Darwin) platforms (generates artifacts for both arm64 and amd64). + platforms: [darwin] + cmds: + - cmd: rm -f dist/bin/wavesrv* + ignore_error: true + - task: build:server:internal + vars: + ARCHS: arm64,amd64 + + build:server:quickdev: + desc: Build the wavesrv component for quickdev (arm64 macOS only, no generate). + platforms: [darwin] + cmds: + - task: build:server:internal + vars: + ARCHS: arm64 + deps: + - go:mod:tidy + sources: + - "cmd/server/*.go" + - "pkg/**/*.go" + - "pkg/**/*.json" + - "pkg/**/*.sh" + - "tsunami/**/*.go" + generates: + - dist/bin/wavesrv.* + + build:backend:quickdev:windows: + desc: Build only the wavesrv component for quickdev (Windows amd64 only, no generate, no wsh). + platforms: [windows] + cmds: + - task: build:server:internal + vars: + ARCHS: amd64 + GO_ENV_VARS: CC="zig cc -target x86_64-windows-gnu" + deps: + - go:mod:tidy + sources: + - "cmd/server/*.go" + - "pkg/**/*.go" + - "pkg/**/*.json" + - "pkg/**/*.sh" + - "tsunami/**/*.go" + generates: + - dist/bin/wavesrv.x64.exe + + build:server:windows: + desc: Build the wavesrv component for Windows platforms (only generates artifacts for the current architecture). + platforms: [windows] + cmds: + - cmd: powershell -NoProfile -NonInteractive -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path dist/bin/wavesrv*" + ignore_error: true + - task: build:server:internal + vars: + ARCHS: + sh: echo {{if eq "arm" ARCH}}arm64{{else}}{{ARCH}}{{end}} + GO_ENV_VARS: + sh: echo "{{if eq "amd64" ARCH}}CC=\"zig cc -target x86_64-windows-gnu\"{{else}}CC=\"zig cc -target aarch64-windows-gnu\"{{end}}" + + build:server:linux: + desc: Build the wavesrv component for Linux platforms (only generates artifacts for the current architecture). + platforms: [linux] + cmds: + - cmd: rm -f dist/bin/wavesrv* + ignore_error: true + - task: build:server:internal + vars: + ARCHS: + sh: echo {{if eq "arm" ARCH}}arm64{{else}}{{ARCH}}{{end}} + GO_ENV_VARS: + sh: echo "{{if eq "amd64" ARCH}}CC=\"zig cc -target x86_64-linux-gnu.2.28\"{{else}}CC=\"zig cc -target aarch64-linux-gnu.2.28\"{{end}}" + + build:server:internal: + requires: + vars: + - ARCHS + cmd: + cmd: CGO_ENABLED=1 GOARCH={{.GOARCH}} {{.GO_ENV_VARS}} go build -tags "osusergo,sqlite_omit_load_extension" -ldflags "{{.GO_LDFLAGS}} -X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.WaveVersion={{.VERSION}}" -o dist/bin/wavesrv.{{if eq .GOARCH "amd64"}}x64{{else}}{{.GOARCH}}{{end}}{{exeExt}} cmd/server/main-server.go + for: + var: ARCHS + split: "," + as: GOARCH + internal: true + + build:wsh: + desc: Build the wsh component for all possible targets. + cmds: + - cmd: rm -f dist/bin/wsh* + platforms: [darwin, linux] + ignore_error: true + - cmd: powershell -NoProfile -NonInteractive -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path dist/bin/wsh*" + platforms: [windows] + ignore_error: true + - task: build:wsh:parallel + deps: + - go:mod:tidy + - generate + sources: + - "cmd/wsh/**/*.go" + - "pkg/**/*.go" + - package.json + generates: + - "dist/bin/wsh*" + + build:wsh:parallel: + deps: + - task: build:wsh:internal + vars: + GOOS: darwin + GOARCH: arm64 + - task: build:wsh:internal + vars: + GOOS: darwin + GOARCH: amd64 + - task: build:wsh:internal + vars: + GOOS: linux + GOARCH: arm64 + - task: build:wsh:internal + vars: + GOOS: linux + GOARCH: amd64 + - task: build:wsh:internal + vars: + GOOS: linux + GOARCH: mips + - task: build:wsh:internal + vars: + GOOS: linux + GOARCH: mips64 + - task: build:wsh:internal + vars: + GOOS: windows + GOARCH: amd64 + - task: build:wsh:internal + vars: + GOOS: windows + GOARCH: arm64 + internal: true + + build:wsh:internal: + vars: + EXT: + sh: echo {{if eq .GOOS "windows"}}.exe{{end}} + NORMALIZEDARCH: + sh: echo {{if eq .GOARCH "amd64"}}x64{{else}}{{.GOARCH}}{{end}} + requires: + vars: + - GOOS + - GOARCH + - VERSION + cmd: (CGO_ENABLED=0 GOOS={{.GOOS}} GOARCH={{.GOARCH}} go build -ldflags="-s -w -X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.WaveVersion={{.VERSION}}" -o dist/bin/wsh-{{.VERSION}}-{{.GOOS}}.{{.NORMALIZEDARCH}}{{.EXT}} cmd/wsh/main-wsh.go) + internal: true + + build:tsunamiscaffold: + desc: Build and copy tsunami scaffold to dist directory. + cmds: + - cmd: "{{.RMRF}} dist/tsunamiscaffold" + ignore_error: true + - task: copyfiles:'tsunami/frontend/scaffold':'dist/tsunamiscaffold' + - cmd: '{{if eq OS "windows"}}powershell -NoProfile -NonInteractive Copy-Item -Path tsunami/templates/empty-gomod.tmpl -Destination dist/tsunamiscaffold/go.mod{{else}}cp tsunami/templates/empty-gomod.tmpl dist/tsunamiscaffold/go.mod{{end}}' + deps: + - tsunami:scaffold + sources: + - "tsunami/frontend/dist/**/*" + - "tsunami/templates/**/*" + generates: + - "dist/tsunamiscaffold/**/*" + + generate: + desc: Generate Typescript bindings for the Go backend. + cmds: + - go run cmd/generatets/main-generatets.go + - go run cmd/generatego/main-generatego.go + deps: + - build:schema + sources: + - "cmd/generatego/*.go" + - "cmd/generatets/*.go" + - "pkg/**/*.go" + # don't add generates key (otherwise will always execute) + + sync:models: + desc: | + Re-fetch the LiteLLM model registry and regenerate + frontend/app/store/ai-catalog-models.gen.ts. Run after a + major OpenAI/Anthropic/Google model release; otherwise the + checked-in file is the source of truth for the AI picker. + cmds: + - node scripts/sync-ai-models.mjs + sources: + - "scripts/sync-ai-models.mjs" + generates: + - "frontend/app/store/ai-catalog-models.gen.ts" + + outdated: + desc: Check for outdated packages using npm-check-updates. + cmd: npx npm-check-updates@latest + + version: + desc: Get the current package version, or bump version if args are present. To pass args to `version.cjs`, add them after `--`. See `version.cjs` for usage definitions for the arguments. + cmd: node version.cjs {{.CLI_ARGS}} + + artifacts:upload: + desc: Uploads build artifacts to the staging bucket in S3. To add additional AWS CLI arguments, add them after `--`. + vars: + ORIGIN: "make/" + DESTINATION: "{{.ARTIFACTS_BUCKET}}/{{.VERSION}}" + cmd: aws s3 cp {{.ORIGIN}}/ s3://{{.DESTINATION}}/ --recursive --exclude "*/*" --exclude "builder-*.yml" {{.CLI_ARGS}} + + artifacts:download:*: + desc: Downloads the specified artifacts version from the staging bucket. To add additional AWS CLI arguments, add them after `--`. + vars: + DL_VERSION: '{{ replace "v" "" (index .MATCH 0)}}' + ORIGIN: "{{.ARTIFACTS_BUCKET}}/{{.DL_VERSION}}" + DESTINATION: "artifacts/{{.DL_VERSION}}" + cmds: + - '{{.RMRF}} "{{.DESTINATION}}"' + - aws s3 cp s3://{{.ORIGIN}}/ {{.DESTINATION}}/ --recursive {{.CLI_ARGS}} + + artifacts:publish:*: + desc: Publishes the specified artifacts version from the staging bucket to the releases bucket. To add additional AWS CLI arguments, add them after `--`. + vars: + UP_VERSION: '{{ replace "v" "" (index .MATCH 0)}}' + ORIGIN: "{{.ARTIFACTS_BUCKET}}/{{.UP_VERSION}}" + DESTINATION: "{{.RELEASES_BUCKET}}" + cmd: | + OUTPUT=$(aws s3 cp s3://{{.ORIGIN}}/ s3://{{.DESTINATION}}/ --recursive {{.CLI_ARGS}}) + + for line in $OUTPUT; do + PREFIX=${line%%{{.DESTINATION}}*} + SUFFIX=${line:${#PREFIX}} + if [[ -n "$SUFFIX" ]]; then + echo "https://$SUFFIX" + fi + done + artifacts:snap:publish:*: + desc: Publishes the specified artifacts version to Snapcraft. + vars: + UP_VERSION: '{{ replace "v" "" (index .MATCH 0)}}' + CHANNEL: '{{if contains "beta" .UP_VERSION}}beta{{else}}beta,stable{{end}}' + cmd: | + echo "Releasing to channels: [{{.CHANNEL}}]" + for file in waveterm_{{.UP_VERSION}}_*.snap; do + echo "Publishing $file" + snapcraft upload --release={{.CHANNEL}} $file + echo "Finished publishing $file" + done + + artifacts:winget:publish:*: + desc: Submits a version bump request to WinGet for the latest release. + status: + - exit {{if contains "beta" .UP_VERSION}}0{{else}}1{{end}} + vars: + UP_VERSION: '{{ replace "v" "" (index .MATCH 0)}}' + cmd: | + wingetcreate update {{.WINGET_PACKAGE}} -s -v {{.UP_VERSION}} -u "https://{{.RELEASES_BUCKET}}/{{.APP_NAME}}-win32-x64-{{.UP_VERSION}}.msi" -t {{.GITHUB_TOKEN}} + + dev:installwsh: + desc: quick shortcut to rebuild wsh and install for macos arm64 + requires: + vars: + - VERSION + cmds: + - task: build:wsh:internal + vars: + GOOS: darwin + GOARCH: arm64 + - cp dist/bin/wsh-{{.VERSION}}-darwin.arm64 ~/Library/Application\ Support/waveterm-dev/bin/wsh + + dev:clearconfig: + desc: Clear the config directory for waveterm-dev + cmd: "{{.RMRF}} ~/.config/waveterm-dev" + + dev:cleardata: + desc: Clear the data directory for waveterm-dev + cmds: + - task: dev:cleardata:windows + - task: dev:cleardata:linux + - task: dev:cleardata:macos + + check:ts: + desc: Typecheck TypeScript code (frontend and electron). + cmd: npx tsc --noEmit + deps: + - npm:install + + init: + desc: Initialize the project for development. + cmds: + - npm install + - go mod tidy + - cd docs && npm install + + dev:cleardata:windows: + internal: true + platforms: [windows] + cmd: '{{.RMRF}} %LOCALAPPDATA%\waveterm-dev\Data' + + dev:cleardata:linux: + internal: true + platforms: [linux] + cmd: "rm -rf ~/.local/share/waveterm-dev" + + dev:cleardata:macos: + internal: true + platforms: [darwin] + cmd: 'rm -rf ~/Library/Application\ Support/waveterm-dev' + + npm:install: + desc: Runs `npm install` + internal: true + generates: + - node_modules/**/* + - package-lock.json + sources: + - package-lock.json + - package.json + cmd: npm install + + go:mod:tidy: + desc: Runs `go mod tidy` + internal: true + generates: + - go.sum + sources: + - go.mod + cmd: go mod tidy + + copyfiles:*:*: + desc: Recursively copy directory and its contents. + internal: true + cmd: '{{if eq OS "windows"}}powershell -NoProfile -NonInteractive Copy-Item -Recurse -Force -Path {{index .MATCH 0}} -Destination {{index .MATCH 1}}{{else}}mkdir -p "$(dirname {{index .MATCH 1}})" && cp -r {{index .MATCH 0}} {{index .MATCH 1}}{{end}}' + + clean: + desc: clean make/dist directories + cmds: + - cmd: '{{.RMRF}} "make"' + ignore_error: true + - cmd: '{{.RMRF}} "dist"' + ignore_error: true + + tsunami:demo:todo: + desc: Run the tsunami todo demo application + cmd: go run demo/todo/*.go + dir: tsunami + env: + TSUNAMI_LISTENADDR: "localhost:12026" + + tsunami:frontend:dev: + desc: Run the tsunami frontend vite dev server + cmd: npm run dev + dir: tsunami/frontend + + tsunami:frontend:build: + desc: Build the tsunami frontend + cmd: npm run build + dir: tsunami/frontend + + tsunami:frontend:devbuild: + desc: Build the tsunami frontend in development mode (with source maps and symbols) + cmd: npm run build:dev + dir: tsunami/frontend + + tsunami:scaffold: + desc: Build scaffold for tsunami frontend development + deps: + - tsunami:frontend:build + cmds: + - task: tsunami:scaffold:internal + + tsunami:devscaffold: + desc: Build scaffold for tsunami frontend development (with source maps and symbols) + deps: + - tsunami:frontend:devbuild + cmds: + - task: tsunami:scaffold:internal + + tsunami:scaffold:packagejson: + desc: Create package.json for tsunami scaffold using npm commands + dir: tsunami/frontend/scaffold + cmds: + - cmd: rm -f package.json + platforms: [darwin, linux] + ignore_error: true + - cmd: powershell -NoProfile -NonInteractive -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path package.json" + platforms: [windows] + ignore_error: true + - npm --no-workspaces init -y --init-license Apache-2.0 + - npm pkg set name=tsunami-scaffold + - npm pkg delete author + - npm pkg set author.name="Command Line Inc" + - npm pkg set author.email="info@commandline.dev" + - npm --no-workspaces install tailwindcss@4.1.13 @tailwindcss/cli@4.1.13 + + tsunami:scaffold:internal: + desc: Internal task to create scaffold directory structure + internal: true + cmds: + - task: tsunami:scaffold:internal:unix + - task: tsunami:scaffold:internal:windows + + tsunami:scaffold:internal:unix: + desc: Internal task to create scaffold directory structure (Unix) + dir: tsunami/frontend + internal: true + platforms: [darwin, linux] + cmds: + - cmd: "{{.RMRF}} scaffold" + ignore_error: true + - mkdir -p scaffold + - cp ../templates/package.json.tmpl scaffold/package.json + - cd scaffold && npm install + - mv scaffold/node_modules scaffold/nm + - cp -r dist scaffold/ + - mkdir -p scaffold/dist/tw + - cp ../templates/*.go.tmpl scaffold/ + - cp ../templates/tailwind.css scaffold/ + - cp ../templates/gitignore.tmpl scaffold/.gitignore + - cp src/element/*.tsx scaffold/dist/tw/ + - cp ../ui/*.go scaffold/dist/tw/ + - cp ../engine/errcomponent.go scaffold/dist/tw/ + + tsunami:scaffold:internal:windows: + desc: Internal task to create scaffold directory structure (Windows) + dir: tsunami/frontend + internal: true + platforms: [windows] + cmds: + - cmd: "{{.RMRF}} scaffold" + ignore_error: true + - powershell -NoProfile -NonInteractive New-Item -ItemType Directory -Force -Path scaffold + - powershell -NoProfile -NonInteractive Copy-Item -Path ../templates/package.json.tmpl -Destination scaffold/package.json + - powershell -NoProfile -NonInteractive -Command "Set-Location scaffold; npm install" + - powershell -NoProfile -NonInteractive Move-Item -Path scaffold/node_modules -Destination scaffold/nm + - powershell -NoProfile -NonInteractive Copy-Item -Recurse -Force -Path dist -Destination scaffold/ + - powershell -NoProfile -NonInteractive New-Item -ItemType Directory -Force -Path scaffold/dist/tw + - powershell -NoProfile -NonInteractive Copy-Item -Path '../templates/*.go.tmpl' -Destination scaffold/ + - powershell -NoProfile -NonInteractive Copy-Item -Path ../templates/tailwind.css -Destination scaffold/ + - powershell -NoProfile -NonInteractive Copy-Item -Path ../templates/gitignore.tmpl -Destination scaffold/.gitignore + - powershell -NoProfile -NonInteractive Copy-Item -Path 'src/element/*.tsx' -Destination scaffold/dist/tw/ + - powershell -NoProfile -NonInteractive Copy-Item -Path '../ui/*.go' -Destination scaffold/dist/tw/ + - powershell -NoProfile -NonInteractive Copy-Item -Path ../engine/errcomponent.go -Destination scaffold/dist/tw/ + + tsunami:build: + desc: Build the tsunami binary. + cmds: + - cmd: rm -f bin/tsunami* + platforms: [darwin, linux] + ignore_error: true + - cmd: powershell -NoProfile -NonInteractive -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path bin/tsunami*" + platforms: [windows] + ignore_error: true + - mkdir -p bin + - cd tsunami && go build -ldflags "-X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.TsunamiVersion={{.VERSION}}" -o ../bin/tsunami{{exeExt}} cmd/main-tsunami.go + sources: + - "tsunami/**/*.go" + - "tsunami/go.mod" + - "tsunami/go.sum" + generates: + - "bin/tsunami{{exeExt}}" + + tsunami:clean: + desc: Clean tsunami frontend build artifacts + dir: tsunami/frontend + cmds: + - cmd: "{{.RMRF}} dist" + ignore_error: true + - cmd: "{{.RMRF}} scaffold" + ignore_error: true + + godoc: + desc: Start the Go documentation server for the root module + cmd: $(go env GOPATH)/bin/pkgsite -http=:6060 + + tsunami:godoc: + desc: Start the Go documentation server for the tsunami module + cmd: $(go env GOPATH)/bin/pkgsite -http=:6060 + dir: tsunami diff --git a/aiprompts/blockcontroller-lifecycle.md b/aiprompts/blockcontroller-lifecycle.md new file mode 100644 index 000000000..4fa6e1b32 --- /dev/null +++ b/aiprompts/blockcontroller-lifecycle.md @@ -0,0 +1,291 @@ +# Block Controller Lifecycle + +## Overview + +Block controllers manage the execution lifecycle of terminal shells, commands, and other interactive processes. **The frontend drives the controller lifecycle** - the backend is reactive, creating and managing controllers in response to frontend requests. + +## Controller States + +Controllers have three primary states: +- **`init`** - Controller exists but process is not running +- **`running`** - Process is actively running +- **`done`** - Process has exited + +## Architecture Components + +### Backend: Controller Registry + +Location: [`pkg/blockcontroller/blockcontroller.go`](pkg/blockcontroller/blockcontroller.go) + +The backend maintains a **global controller registry** that maps blockIds to controller instances: + +```go +var ( + controllerRegistry = make(map[string]Controller) + registryLock sync.RWMutex +) +``` + +Controllers implement the [`Controller` interface](pkg/blockcontroller/blockcontroller.go:64): +- `Start(ctx, blockMeta, rtOpts, force)` - Start the controller process +- `Stop(graceful, newStatus)` - Stop the controller process +- `GetRuntimeStatus()` - Get current runtime status +- `SendInput(input)` - Send input (data, signals, terminal size) to the process + +### Frontend: View Model + +Location: [`frontend/app/view/term/term-model.ts`](frontend/app/view/term/term-model.ts) + +The [`TermViewModel`](frontend/app/view/term/term-model.ts:44) manages the frontend side of a terminal block: + +**Key Atoms:** +- `shellProcFullStatus` - Holds the current controller status from backend +- `shellProcStatus` - Derived atom for just the status string ("init", "running", "done") +- `isRestarting` - UI state for restart animation + +**Event Subscription:** +The constructor subscribes to controller status events (line 317-324): +```typescript +this.shellProcStatusUnsubFn = waveEventSubscribe({ + eventType: "controllerstatus", + scope: WOS.makeORef("block", blockId), + handler: (event) => { + let bcRTS: BlockControllerRuntimeStatus = event.data; + this.updateShellProcStatus(bcRTS); + }, +}); +``` + +This creates a **reactive data flow**: backend publishes status updates → frontend receives via WebSocket events → UI updates automatically via Jotai atoms. + +## Lifecycle Flow + +### 1. Frontend Triggers Controller Creation/Start + +**Entry Point:** [`ResyncController()`](pkg/blockcontroller/blockcontroller.go:120) RPC endpoint + +The frontend calls this via [`RpcApi.ControllerResyncCommand`](frontend/app/view/term/term-model.ts:661) when: + +1. **Manual Restart** - User clicks restart button or presses Enter when process is done + - Triggered by [`forceRestartController()`](frontend/app/view/term/term-model.ts:652) + - Passes `forcerestart: true` flag + - Includes current terminal size (`termsize: { rows, cols }`) + +2. **Connection Status Changes** - Connection becomes available/unavailable + - Monitored by [`TermResyncHandler`](frontend/app/view/term/term.tsx:34) component + - Watches `connStatus` atom for changes + - Calls `termRef.current?.resyncController("resync handler")` + +3. **Block Meta Changes** - Configuration like controller type or connection changes + - Happens when block metadata is updated + - Backend detects changes and triggers resync + +### 2. Backend Processes Resync Request + +The [`ResyncController()`](pkg/blockcontroller/blockcontroller.go:120) function: + +```go +func ResyncController(ctx context.Context, tabId, blockId string, + rtOpts *waveobj.RuntimeOpts, force bool) error +``` + +**Steps:** + +1. **Get Block Data** - Fetch block metadata from database +2. **Determine Controller Type** - Read `controller` meta key ("shell", "cmd", "tsunami") +3. **Check Existing Controller:** + - If controller type changed → stop old, create new + - If connection changed (for shell/cmd) → stop and restart + - If `force=true` → stop existing +4. **Register Controller** - Add to registry (replaces existing if present) +5. **Check if Start Needed** - If status is "init" or "done": + - For remote connections: verify connection status first + - Call `controller.Start(ctx, blockMeta, rtOpts, force)` +6. **Publish Status** - Controller publishes runtime status updates + +**Important:** Registering a new controller automatically stops any existing controller for that blockId (line 95-98): +```go +if existingController != nil { + existingController.Stop(false, Status_Done) + wstore.DeleteRTInfo(waveobj.MakeORef(waveobj.OType_Block, blockId)) +} +``` + +### 3. Backend Publishes Status Updates + +Controllers publish their status via the event system when: +- Process starts +- Process state changes +- Process exits + +The status includes: +- `shellprocstatus` - "init", "running", or "done" +- `shellprocconnname` - Connection name being used +- `shellprocexitcode` - Exit code when done +- `version` - Incrementing version number for ordering + +### 4. Frontend Receives and Processes Updates + +**Status Update Handler** (line 321-323): +```typescript +handler: (event) => { + let bcRTS: BlockControllerRuntimeStatus = event.data; + this.updateShellProcStatus(bcRTS); +} +``` + +**Status Update Logic** (line 430-438): +```typescript +updateShellProcStatus(fullStatus: BlockControllerRuntimeStatus) { + if (fullStatus == null) return; + const curStatus = globalStore.get(this.shellProcFullStatus); + // Only update if newer version + if (curStatus == null || curStatus.version < fullStatus.version) { + globalStore.set(this.shellProcFullStatus, fullStatus); + } +} +``` + +The version check ensures out-of-order events don't cause issues. + +### 5. UI Updates Reactively + +The UI reacts to status changes through Jotai atoms: + +**Header Buttons** (line 263-306): +- Show "Play" icon when status is "init" +- Show "Refresh" icon when status is "running" or "done" +- Display exit code/status icons for cmd controller + +**Restart Behavior** (line 631-635 in term.tsx via term-model.ts): +```typescript +const shellProcStatus = globalStore.get(this.shellProcStatus); +if ((shellProcStatus == "done" || shellProcStatus == "init") && + keyutil.checkKeyPressed(waveEvent, "Enter")) { + this.forceRestartController(); + return false; +} +``` + +Pressing Enter when the process is done/init triggers a restart. + +## Input Flow + +**Frontend → Backend:** + +When user types in terminal, data flows through [`sendDataToController()`](frontend/app/view/term/term-model.ts:408): +```typescript +sendDataToController(data: string) { + const b64data = stringToBase64(data); + RpcApi.ControllerInputCommand(TabRpcClient, { + blockid: this.blockId, + inputdata64: b64data + }); +} +``` + +This calls the backend [`SendInput()`](pkg/blockcontroller/blockcontroller.go:260) function which forwards to the controller's `SendInput()` method. + +The [`BlockInputUnion`](pkg/blockcontroller/blockcontroller.go:48) supports three types of input: +- `inputdata` - Raw terminal input bytes +- `signame` - Signal names (e.g., "SIGTERM", "SIGINT") +- `termsize` - Terminal size changes (rows/cols) + +## Key Design Principles + +### 1. Frontend-Driven Architecture + +The frontend has full control over controller lifecycle: +- **Creates** controllers by calling ResyncController +- **Restarts** controllers via forcerestart flag +- **Monitors** status via event subscriptions +- **Sends input** via ControllerInput RPC + +The backend is stateless and reactive - it doesn't make lifecycle decisions autonomously. + +### 2. Idempotent Resync + +`ResyncController()` is idempotent - calling it multiple times with the same state is safe: +- If controller exists and is running with correct type/connection → no-op +- If configuration changed → replaces controller +- If force flag set → always restarts + +This makes it safe to call on various triggers (connection change, focus, etc.). + +### 3. Versioned Status Updates + +Status includes a monotonically increasing version number: +- Frontend can process events out-of-order +- Only applies updates with newer versions +- Prevents race conditions from concurrent updates + +### 4. Automatic Cleanup + +When a controller is replaced: +- Old controller is automatically stopped +- Runtime info is cleaned up +- Registry entry is updated atomically + +The `registerController()` function handles this automatically (line 84-99). + +## Common Patterns + +### Restarting a Controller + +```typescript +// In term-model.ts +forceRestartController() { + this.triggerRestartAtom(); // UI feedback + const termsize = { + rows: this.termRef.current?.terminal?.rows, + cols: this.termRef.current?.terminal?.cols, + }; + RpcApi.ControllerResyncCommand(TabRpcClient, { + tabid: globalStore.get(atoms.staticTabId), + blockid: this.blockId, + forcerestart: true, + rtopts: { termsize: termsize }, + }); +} +``` + +### Handling Connection Changes + +```typescript +// In term.tsx - TermResyncHandler component +React.useEffect(() => { + const isConnected = connStatus?.status == "connected"; + const wasConnected = lastConnStatus?.status == "connected"; + if (isConnected == wasConnected && curConnName == lastConnName) { + return; // No change + } + model.termRef.current?.resyncController("resync handler"); + setLastConnStatus(connStatus); +}, [connStatus]); +``` + +### Monitoring Status + +```typescript +// Status is automatically available via atom +const shellProcStatus = jotai.useAtomValue(model.shellProcStatus); + +// Use in UI +if (shellProcStatus == "running") { + // Show running state +} else if (shellProcStatus == "done") { + // Show restart button +} +``` + +## Summary + +The block controller lifecycle is **frontend-driven and event-reactive**: + +1. **Frontend triggers** controller creation/restart via `ControllerResyncCommand` RPC +2. **Backend processes** the request in `ResyncController()`, creating/starting controllers as needed +3. **Backend publishes** status updates via WebSocket events +4. **Frontend receives** status updates and updates Jotai atoms +5. **UI reacts** automatically to atom changes via React components + +This architecture gives the frontend full control over when processes start/stop while keeping the backend focused on process management. The event-based status updates create a clean separation of concerns and enable real-time UI updates without polling. diff --git a/aiprompts/config-system.md b/aiprompts/config-system.md new file mode 100644 index 000000000..65fd996f5 --- /dev/null +++ b/aiprompts/config-system.md @@ -0,0 +1,368 @@ +# Wave Terminal Configuration System + +This document explains how Wave Terminal's configuration system works and provides step-by-step instructions for adding new configuration values. + +## Overview + +Wave Terminal uses a hierarchical configuration system with the following components: + +1. **Go Struct Definitions** - Type-safe configuration structure in Go +2. **JSON Schema** - Validation schema for configuration files +3. **Default Values** - Built-in default configuration +4. **User Configuration** - User-customizable settings in `~/.config/waveterm/settings.json` +5. **Documentation** - User-facing documentation + +## Configuration File Structure + +Wave Terminal's configuration system is organized into several key directories and files: + +``` +waveterm/ +├── pkg/wconfig/ # Go configuration package +│ ├── settingsconfig.go # Main settings struct definitions +│ ├── defaultconfig/ # Default configuration files +│ │ ├── settings.json # Default settings values +│ │ ├── termthemes.json # Default terminal themes +│ │ ├── presets.json # Default background presets +│ │ └── widgets.json # Default widget configurations +│ └── ... # Other config-related Go files +├── schema/ # JSON Schema definitions +│ ├── settings.json # Settings validation schema +│ └── ... # Other schema files +├── docs/docs/ # User documentation +│ └── config.mdx # Configuration documentation +└── ~/.config/waveterm/ # User config directory (runtime) + ├── settings.json # User settings overrides + ├── termthemes.json # User terminal themes + ├── presets.json # User background presets + ├── widgets.json # User widget configurations + ├── bookmarks.json # Web bookmarks + └── connections.json # SSH/remote connections +``` + +**Key Files:** + +- **[`pkg/wconfig/settingsconfig.go`](pkg/wconfig/settingsconfig.go)** - Defines the `SettingsType` struct with all configuration fields +- **[`schema/settings.json`](schema/settings.json)** - JSON Schema for validation and type checking +- **[`pkg/wconfig/defaultconfig/settings.json`](pkg/wconfig/defaultconfig/settings.json)** - Default values for all settings +- **[`docs/docs/config.mdx`](docs/docs/config.mdx)** - User-facing documentation with descriptions and examples + +## Configuration Architecture + +### Configuration Hierarchy + +1. **Built-in Defaults** (`pkg/wconfig/defaultconfig/settings.json`) +2. **User Settings** (`~/.config/waveterm/settings.json`) +3. **Block-level Overrides** (stored in block metadata) + +Settings cascade from defaults → user settings → block overrides. + +### Block-Level Metadata Override System + +Wave Terminal supports block-level configuration overrides through the metadata system. This allows settings to be applied globally, per-connection, or per-block: + +1. **Global Settings** (`~/.config/waveterm/settings.json`) - Apply to all blocks by default +2. **Connection Settings** (in connections config) - Apply to all blocks using a specific connection +3. **Block Metadata** - Override settings for individual blocks + +**Key Files for Block Overrides:** + +- **[`pkg/waveobj/wtypemeta.go`](pkg/waveobj/wtypemeta.go)** - Defines the `MetaTSType` struct for block-level metadata +- Block metadata fields should match the corresponding settings fields for consistency + +**Frontend Usage:** + +```typescript +// Use getOverrideConfigAtom for hierarchical config resolution +const settingValue = useAtomValue(getOverrideConfigAtom(blockId, "namespace:setting")); + +// This automatically resolves in order: block metadata → connection config → global settings → default +``` + +**Setting Block Metadata:** + +```bash +# Set for current block +wsh setmeta namespace:setting=value + +# Set for specific block +wsh setmeta --block BLOCK_ID namespace:setting=value +``` + +## How to Add a New Configuration Value + +Follow these steps to add a new configuration setting: + +### Step 1: Add to Go Struct Definition + +Edit [`pkg/wconfig/settingsconfig.go`](pkg/wconfig/settingsconfig.go) and add your new field to the `SettingsType` struct: + +```go +type SettingsType struct { + // ... existing fields ... + + // Add your new field with appropriate JSON tag + MyNewSetting string `json:"mynew:setting,omitempty"` + + // For different types: + MyBoolSetting bool `json:"mynew:boolsetting,omitempty"` + MyNumberSetting float64 `json:"mynew:numbersetting,omitempty"` + MyIntSetting *int64 `json:"mynew:intsetting,omitempty"` // Use pointer for optional ints + MyArraySetting []string `json:"mynew:arraysetting,omitempty"` +} +``` + +**Naming Conventions:** + +- Use namespace prefixes (e.g., `term:`, `window:`, `ai:`, `web:`) +- Use lowercase with colons as separators +- Field names should be descriptive and follow Go naming conventions +- Use `omitempty` tag to exclude empty values from JSON + +**Type Guidelines:** + +- Use `*int64` and `*float64` for optional numeric values +- Use `*bool` for optional boolean values +- Use `string` for text values +- Use `[]string` for arrays +- Use `float64` for numbers that can be decimals + +### Step 1.5: Add to Block Metadata (Optional) + +If your setting should support block-level overrides, also add it to [`pkg/waveobj/wtypemeta.go`](pkg/waveobj/wtypemeta.go): + +```go +type MetaTSType struct { + // ... existing fields ... + + // Add your new field with matching JSON tag and type + MyNewSetting *string `json:"mynew:setting,omitempty"` // Use pointer for optional values + + // For different types: + MyBoolSetting *bool `json:"mynew:boolsetting,omitempty"` + MyNumberSetting *float64 `json:"mynew:numbersetting,omitempty"` + MyIntSetting *int `json:"mynew:intsetting,omitempty"` + MyArraySetting []string `json:"mynew:arraysetting,omitempty"` +} +``` + +**Block Metadata Guidelines:** + +- Use pointer types (`*string`, `*bool`, `*int`, `*float64`) for optional overrides +- JSON tags should exactly match the corresponding settings field +- This enables the hierarchical config system: block metadata → connection config → global settings + +### Step 2: Set Default Value (Optional) + +If your setting should have a default value, add it to [`pkg/wconfig/defaultconfig/settings.json`](pkg/wconfig/defaultconfig/settings.json): + +```json +{ + "ai:preset": "ai@global", + "ai:model": "gpt-5-mini", + // ... existing defaults ... + + "mynew:setting": "default value", + "mynew:boolsetting": true, + "mynew:numbersetting": 42.5, + "mynew:intsetting": 100 +} +``` + +**Default Value Guidelines:** + +- Only add defaults for settings that should have non-zero/non-empty initial values +- Ensure defaults make sense for the typical user experience +- Keep defaults conservative and safe + +### Step 3: Update Documentation + +Add your new setting to the configuration table in [`docs/docs/config.mdx`](docs/docs/config.mdx): + +```markdown +| Key Name | Type | Function | +| ------------------- | -------- | ----------------------------------------- | +| mynew:setting | string | Description of what this setting controls | +| mynew:boolsetting | bool | Enable/disable some feature | +| mynew:numbersetting | float | Numeric setting for some parameter | +| mynew:intsetting | int | Integer setting for some configuration | +| mynew:arraysetting | string[] | Array of strings for multiple values | +``` + +Also update the default configuration example in the same file if you added defaults. + +### Step 4: Regenerate Schema and TypeScript Types + +Run the generate task to automatically regenerate the JSON schema and TypeScript types: + +```bash +task generate +``` + +**What this does:** +- Runs `task build:schema` (automatically generates JSON schema from Go structs) +- Generates TypeScript type definitions in [`frontend/types/gotypes.d.ts`](frontend/types/gotypes.d.ts) +- Generates RPC client APIs +- Generates metadata constants + +**Note:** The JSON schema in [`schema/settings.json`](schema/settings.json) is **automatically generated** from the Go struct definitions - you don't need to edit it manually. + +### Step 5: Use in Frontend Code + +Access your new setting in React components: + +```typescript +import { getOverrideConfigAtom, useAtomValue } from "@/store/global"; + +// In a React component +const MyComponent = ({ blockId }: { blockId: string }) => { + // Use override config atom for hierarchical resolution + // This automatically checks: block metadata → connection config → global settings → default + const mySettingAtom = getOverrideConfigAtom(blockId, "mynew:setting"); + const mySetting = useAtomValue(mySettingAtom) ?? "fallback value"; + + // For global-only settings (no block overrides) + const globalOnlySetting = useAtomValue(getSettingsKeyAtom("mynew:globalsetting")) ?? "fallback"; + + return
Setting value: {mySetting}
; +}; +``` + +**Frontend Configuration Patterns:** + +```typescript +// 1. Settings with block-level overrides (recommended) +const termFontSize = useAtomValue(getOverrideConfigAtom(blockId, "term:fontsize")) ?? 12; + +// 2. Global-only settings +const appGlobalHotkey = useAtomValue(getSettingsKeyAtom("app:globalhotkey")) ?? ""; + +// 3. Connection-specific settings +const connStatus = useAtomValue(getConnStatusAtom(connectionName)); +``` + +### Step 6: Use in Backend Code + +Access settings in Go code: + +```go +// Get the full config +fullConfig := wconfig.GetWatcher().GetFullConfig() + +// Access your setting +myValue := fullConfig.Settings.MyNewSetting +``` + +## Configuration Patterns + +### Namespace Organization + +Settings are organized by namespace using colon separators: + +- `app:*` - Application-level settings +- `term:*` - Terminal-specific settings +- `window:*` - Window and UI settings +- `ai:*` - AI-related settings +- `web:*` - Web browser settings +- `editor:*` - Code editor settings +- `conn:*` - Connection settings + +### Clear/Reset Pattern + +Each namespace can have a "clear" field for resetting all settings in that namespace: + +```go +AppClear bool `json:"app:*,omitempty"` +TermClear bool `json:"term:*,omitempty"` +``` + +### Optional vs Required Settings + +- Use pointer types (`*bool`, `*int64`, `*float64`) for truly optional settings +- Use regular types for settings that should always have a value +- Provide sensible defaults for important settings + +### Block-Level Overrides + +Settings can be overridden at the block level using metadata: + +```typescript +// Set block-specific override +await RpcApi.SetMetaCommand(TabRpcClient, { + oref: WOS.makeORef("block", blockId), + meta: { "mynew:setting": "block-specific value" }, +}); +``` + +## Example: Adding a New Terminal Setting + +Here's a complete example adding a new terminal setting `term:bellsound` with block-level override support: + +### 1. Go Struct (settingsconfig.go) + +```go +type SettingsType struct { + // ... existing fields ... + TermBellSound string `json:"term:bellsound,omitempty"` +} +``` + +### 2. Block Metadata (wtypemeta.go) + +```go +type MetaTSType struct { + // ... existing fields ... + TermBellSound *string `json:"term:bellsound,omitempty"` // Pointer for optional override +} +``` + +### 3. Default Value (defaultconfig/settings.json - optional) + +```json +{ + "term:bellsound": "default" +} +``` + +### 4. Documentation (docs/config.mdx) + +```markdown +| term:bellsound | string | Sound to play for terminal bell ("default", "none", or custom sound file path) | +``` + +### 5. Regenerate Types + +```bash +task generate +``` + +### 6. Frontend Usage + +```typescript +// Use override config for hierarchical resolution +const bellSoundAtom = getOverrideConfigAtom(blockId, "term:bellsound"); +const bellSound = useAtomValue(bellSoundAtom) ?? "default"; +``` + +### 7. Usage Examples + +```bash +# Set globally +wsh setconfig term:bellsound="custom.wav" + +# Set for current block only +wsh setmeta term:bellsound="none" + +# Set for specific block +wsh setmeta --block BLOCK_ID term:bellsound="beep" +``` + +## Testing Your Configuration + +1. **Build and run** Wave Terminal with your changes +2. **Test default behavior** - Ensure the default value works +3. **Test user override** - Add your setting to `~/.config/waveterm/settings.json` +4. **Test block override** - Set block-specific metadata +5. **Verify schema validation** - Ensure invalid values are rejected + +## Common Pitfalls diff --git a/aiprompts/conn-arch.md b/aiprompts/conn-arch.md new file mode 100644 index 000000000..b03abbad2 --- /dev/null +++ b/aiprompts/conn-arch.md @@ -0,0 +1,612 @@ +# Wave Terminal Connection Architecture + +## Overview + +Wave Terminal's connection system is designed to provide a unified interface for running shell processes across local, SSH, and WSL environments. The architecture is built in layers, with clear separation of concerns between connection management, shell process execution, and block-level orchestration. + +## Architecture Layers + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Block Controllers │ +│ (blockcontroller/blockcontroller.go, shellcontroller.go) │ +│ - Block lifecycle management │ +│ - Controller registry and switching │ +│ - Connection status verification │ +└─────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ Connection Controllers (ConnUnion) │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Local │ │ SSH │ │ WSL │ │ +│ │ │ │ (conncontrol │ │ (wslconn) │ │ +│ │ │ │ ler) │ │ │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ - Connection lifecycle (init → connecting → connected) │ +│ - WSH (Wave Shell Extensions) management │ +│ - Domain socket setup for RPC communication │ +└─────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ Shell Process Execution │ +│ (shellexec/shellexec.go) │ +│ - ShellProc wrapper for running processes │ +│ - PTY management │ +│ - Process lifecycle (start, wait, kill) │ +└─────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ Low-Level Connection Implementation │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ os/exec │ │golang.org/x/ │ │ pkg/wsl │ │ +│ │ │ │ crypto/ssh │ │ │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ - Local process spawning │ +│ - SSH protocol implementation │ +│ - WSL command execution │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Key Components + +### 1. Block Controllers (`pkg/blockcontroller/`) + +**Primary Files:** +- [`blockcontroller.go`](../pkg/blockcontroller/blockcontroller.go) - Controller registry and orchestration +- [`shellcontroller.go`](../pkg/blockcontroller/shellcontroller.go) - Shell/terminal controller implementation + +**Responsibilities:** +- **Controller Registry**: Maintains a global map of active block controllers (`controllerRegistry`) +- **Lifecycle Management**: Handles controller creation, starting, stopping, and switching +- **Connection Verification**: Checks connection status before starting shell processes ([`CheckConnStatus()`](../pkg/blockcontroller/blockcontroller.go:360)) +- **Controller Types**: Supports different controller types (shell, cmd, tsunami) + +**Key Functions:** +- [`ResyncController()`](../pkg/blockcontroller/blockcontroller.go:120) - Main entry point for synchronizing block state with desired controller +- [`registerController()`](../pkg/blockcontroller/blockcontroller.go:84) - Registers a new controller, stopping any existing one +- [`getController()`](../pkg/blockcontroller/blockcontroller.go:78) - Retrieves active controller for a block + +**ShellController Details:** +- Implements the `Controller` interface +- Manages shell processes via [`ShellProc`](../pkg/shellexec/shellexec.go:48) +- Handles three connection types via `ConnUnion`: + - **Local**: Direct process execution on local machine + - **SSH**: Remote execution via SSH connections + - **WSL**: Windows Subsystem for Linux execution +- Key methods: + - [`setupAndStartShellProcess()`](../pkg/blockcontroller/shellcontroller.go:364) - Sets up and starts shell process + - [`getConnUnion()`](../pkg/blockcontroller/shellcontroller.go:321) - Determines connection type and retrieves connection object + - [`manageRunningShellProcess()`](../pkg/blockcontroller/shellcontroller.go:500+) - Manages I/O for running process + +### 2. Connection Controllers + +#### SSH Connections (`pkg/remote/conncontroller/`) + +**Primary File:** [`conncontroller.go`](../pkg/remote/conncontroller/conncontroller.go) + +**Architecture:** +- **Global Registry**: `clientControllerMap` maintains all SSH connections +- **Connection Lifecycle**: + ``` + init → connecting → connected → (running) → disconnected/error + ``` +- **Thread Safety**: Each connection has its own lock (`SSHConn.Lock`) + +**SSHConn Structure:** +```go +type SSHConn struct { + Lock *sync.Mutex + Status string // Connection state + WshEnabled *atomic.Bool // WSH availability flag + Opts *remote.SSHOpts // Connection parameters + Client *ssh.Client // Underlying SSH client + DomainSockName string // Unix socket for RPC + DomainSockListener net.Listener // Socket listener + ConnController *ssh.Session // Runs "wsh connserver" + Error string // Connection error + WshError string // WSH-specific error + WshVersion string // Installed WSH version + // ... +} +``` + +**Key Responsibilities:** +1. **SSH Client Management**: + - Establishes SSH connections using [`golang.org/x/crypto/ssh`](https://pkg.go.dev/golang.org/x/crypto/ssh) + - Handles authentication (pubkey, password, keyboard-interactive) + - Supports ProxyJump for multi-hop connections + +2. **Domain Socket Setup** ([`OpenDomainSocketListener()`](../pkg/remote/conncontroller/conncontroller.go:201)): + - Creates Unix domain socket on remote host (`/tmp/waveterm-*.sock`) + - Enables bidirectional RPC communication + - Socket used by both connserver and shell processes + +3. **WSH (Wave Shell Extensions) Management**: + - **Version Check** ([`StartConnServer()`](../pkg/remote/conncontroller/conncontroller.go:277)): Runs `wsh version` to check installation + - **Installation** ([`InstallWsh()`](../pkg/remote/conncontroller/conncontroller.go:478)): Copies appropriate WSH binary to remote + - **Update** ([`UpdateWsh()`](../pkg/remote/conncontroller/conncontroller.go:417)): Updates existing WSH installation + - **User Prompts** ([`getPermissionToInstallWsh()`](../pkg/remote/conncontroller/conncontroller.go:434)): Asks user for install permission + +4. **Connection Server** (`wsh connserver`): + - Long-running process on remote host + - Provides RPC services for file operations, command execution, etc. + - Communicates via domain socket + - Template: [`ConnServerCmdTemplate`](../pkg/remote/conncontroller/conncontroller.go:74) + +**Connection Flow:** +``` +1. GetConn(opts) - Retrieve or create connection +2. Connect(ctx) - Initiate connection +3. CheckIfNeedsAuth() - Verify authentication needed +4. OpenDomainSocketListener() - Set up RPC channel +5. StartConnServer() - Launch wsh connserver +6. (Install/Update WSH if needed) +7. Status: Connected - Ready for shell processes +``` + +#### SSH Client (`pkg/remote/sshclient.go`) + +**Responsibilities:** +- **Authentication Methods**: + - Public key with optional passphrase ([`createPublicKeyCallback()`](../pkg/remote/sshclient.go:118)) + - Password authentication ([`createPasswordCallbackPrompt()`](../pkg/remote/sshclient.go:227)) + - Keyboard-interactive ([`createInteractiveKbdInteractiveChallenge()`](../pkg/remote/sshclient.go:264)) + - SSH agent support + +- **Known Hosts Verification** ([`createHostKeyCallback()`](../pkg/remote/sshclient.go:429)): + - Reads `~/.ssh/known_hosts` and global known_hosts + - Prompts user for unknown hosts + - Handles key changes/mismatches + +- **ProxyJump Support**: + - Recursive connection through jump hosts + - Max depth: `SshProxyJumpMaxDepth = 10` + +- **User Interaction**: + - Integrates with Wave's [`userinput`](../pkg/userinput/) system + - Non-blocking prompts for passwords, passphrases, host verification + +#### WSL Connections (`pkg/wslconn/`) + +**Primary File:** [`wslconn.go`](../pkg/wslconn/wslconn.go) + +**Architecture:** +- **Similar to SSH**: Parallel structure to `conncontroller` but for WSL +- **Global Registry**: `clientControllerMap` for WSL connections +- **Connection Naming**: `wsl://[distro-name]` (e.g., `wsl://Ubuntu`) + +**WslConn Structure:** +```go +type WslConn struct { + Lock *sync.Mutex + Status string + WshEnabled *atomic.Bool + Name wsl.WslName // Distro name + Client *wsl.Distro // WSL distro interface + DomainSockName string // Uses RemoteFullDomainSocketPath + ConnController *wsl.WslCmd // Runs "wsh connserver" + // ... similar to SSHConn +} +``` + +**Key Differences from SSH:** +- **No Network Socket**: WSL processes run locally, no SSH connection needed +- **Domain Socket Path**: Uses predetermined path ([`wavebase.RemoteFullDomainSocketPath`](../pkg/wavebase/)) +- **Command Execution**: Uses `wsl.exe` command-line tool +- **Simpler Authentication**: No auth needed, user already logged into Windows + +**Connection Flow:** +``` +1. GetWslConn(distroName) - Get/create WSL connection +2. Connect(ctx) - Start connection process +3. OpenDomainSocketListener() - Set domain socket path (no actual listener) +4. StartConnServer() - Launch wsh connserver in WSL +5. (Install/Update WSH if needed) +6. Status: Connected - Ready for shell processes +``` + +### 3. Shell Process Execution (`pkg/shellexec/`) + +**Primary File:** [`shellexec.go`](../pkg/shellexec/shellexec.go) + +**ShellProc Structure:** +```go +type ShellProc struct { + ConnName string // Connection identifier + Cmd ConnInterface // Actual process interface + CloseOnce *sync.Once // Ensures single close + DoneCh chan any // Signals process completion + WaitErr error // Process exit status +} +``` + +**ConnInterface Implementations:** +- **Local**: [`CombinedConnInterface`](../pkg/shellexec/) wraps `os/exec.Cmd` with PTY +- **SSH**: [`RemoteConnInterface`](../pkg/shellexec/) wraps SSH session +- **WSL**: [`WslConnInterface`](../pkg/shellexec/) wraps WSL command + +**Process Startup Functions:** +- [`StartLocalShellProc()`](../pkg/shellexec/) - Local shell processes +- [`StartRemoteShellProc()`](../pkg/shellexec/) - SSH remote shells (with WSH) +- [`StartRemoteShellProcNoWsh()`](../pkg/shellexec/) - SSH remote shells (no WSH) +- [`StartWslShellProc()`](../pkg/shellexec/) - WSL shells (with WSH) +- [`StartWslShellProcNoWsh()`](../pkg/shellexec/) - WSL shells (no WSH) + +**Key Features:** +- **PTY Management**: Pseudo-terminal for interactive shells +- **Graceful Shutdown**: Sends SIGTERM, waits briefly, then SIGKILL +- **Process Wrapping**: Abstracts differences between local/remote/WSL execution + +### 4. Generic Connection Interface (`pkg/genconn/`) + +**Purpose**: Provides abstraction layer for running commands across different connection types + +**Primary File:** [`ssh-impl.go`](../pkg/genconn/ssh-impl.go) + +**Interface Hierarchy:** +```go +ShellClient -> ShellProcessController +``` + +**SSHShellClient:** +- Wraps `*ssh.Client` +- Creates `SSHProcessController` for each command + +**SSHProcessController:** +- Wraps `*ssh.Session` +- Implements stdio piping (stdin, stdout, stderr) +- Handles command lifecycle (Start, Wait, Kill) +- Thread-safe with internal locking + +**Usage Pattern:** +```go +client := genconn.MakeSSHShellClient(sshClient) +proc, _ := client.MakeProcessController(cmdSpec) +stdout, _ := proc.StdoutPipe() +proc.Start() +// Read from stdout... +proc.Wait() +``` + +### 5. Shell Utilities (`pkg/util/shellutil/`) + +**Primary File:** [`shellutil.go`](../pkg/util/shellutil/shellutil.go) + +**Responsibilities:** + +1. **Shell Detection**: + - [`DetectLocalShellPath()`](../pkg/util/shellutil/shellutil.go:87) - Finds user's default shell + - [`GetShellTypeFromShellPath()`](../pkg/util/shellutil/shellutil.go:462) - Identifies shell type (bash, zsh, fish, pwsh) + - [`DetectShellTypeAndVersion()`](../pkg/util/shellutil/shellutil.go:486) - Gets shell version info + +2. **Shell Integration Files**: + - [`InitCustomShellStartupFiles()`](../pkg/util/shellutil/shellutil.go:270) - Creates Wave's shell integration + - Manages startup files for each shell type: + - Bash: `.bashrc` in `shell/bash/` + - Zsh: `.zshrc`, `.zprofile`, etc. in `shell/zsh/` + - Fish: `wave.fish` in `shell/fish/` + - PowerShell: `wavepwsh.ps1` in `shell/pwsh/` + +3. **Environment Management**: + - [`WaveshellLocalEnvVars()`](../pkg/util/shellutil/shellutil.go:218) - Wave-specific environment variables + - [`UpdateCmdEnv()`](../pkg/util/shellutil/shellutil.go:231) - Updates command environment + +4. **WSH Binary Management**: + - [`GetLocalWshBinaryPath()`](../pkg/util/shellutil/shellutil.go:334) - Locates platform-specific WSH binary + - Supports multiple OS/arch combinations + +5. **Git Bash Detection** (Windows): + - [`FindGitBash()`](../pkg/util/shellutil/shellutil.go:156) - Locates Git Bash installation + - Checks multiple common installation paths + +## Connection Types and Workflows + +### Local Connections + +**Connection Name**: `"local"`, `"local:"`, or `""` (empty) + +**Workflow:** +1. Block controller checks connection type via [`IsLocalConnName()`](../pkg/remote/conncontroller/conncontroller.go:80) +2. No connection setup needed +3. Shell process started directly via [`StartLocalShellProc()`](../pkg/shellexec/) +4. Uses `os/exec.Cmd` with PTY +5. WSH integration via environment variables + +**Special Case - Git Bash (Windows):** +- Variant: `"local:gitbash"` +- Requires special shell path detection +- Uses Git Bash binary instead of default shell + +### SSH Connections + +**Connection Name**: `"user@host:port"` (parsed by [`remote.ParseOpts()`](../pkg/remote/)) + +**Full Connection Workflow:** + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 1. Connection Request (from Block Controller) │ +└─────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ 2. GetConn(opts) - Retrieve/Create SSHConn │ +│ - Check global registry (clientControllerMap) │ +│ - Create new SSHConn if needed │ +│ - Status: "init" │ +└─────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ 3. conn.Connect(ctx) - Establish SSH Connection │ +│ - Status: "connecting" │ +│ - Read SSH config (~/.ssh/config) │ +│ - Resolve ProxyJump if configured │ +│ - Create SSH client auth methods: │ +│ â€ĸ Public key (with agent support) │ +│ â€ĸ Password │ +│ â€ĸ Keyboard-interactive │ +│ - Establish SSH connection │ +│ - Verify known_hosts │ +└─────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ 4. OpenDomainSocketListener(ctx) - Set Up RPC Channel │ +│ - Create random socket path: /tmp/waveterm-[random].sock │ +│ - Use ssh.Client.ListenUnix() for remote forwarding │ +│ - Start RPC listener goroutine │ +│ - Socket available for all subsequent operations │ +└─────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ 5. StartConnServer(ctx) - Launch Wave Shell Extensions │ +│ - Run: "wsh version" to check installation │ +│ - If not installed or outdated: │ +│ a. Detect remote platform (OS/arch) │ +│ b. Get user permission (if configured) │ +│ c. InstallWsh() - Copy binary to remote │ +│ d. Retry StartConnServer() │ +│ - Run: "wsh connserver" on remote │ +│ - Pass JWT token for authentication │ +│ - Monitor connserver output │ +│ - Wait for RPC route registration │ +│ - Status: "connected" │ +└─────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ 6. Connection Ready - Can Start Shell Processes │ +│ - SSHConn available in registry │ +│ - Domain socket active for RPC │ +│ - WSH connserver running │ +└─────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ 7. Start Shell Process (from ShellController) │ +│ - setupAndStartShellProcess() │ +│ - Create swap token (for shell integration) │ +│ - StartRemoteShellProc() or StartRemoteShellProcNoWsh() │ +│ - SSH session created for shell │ +│ - PTY allocated │ +│ - Shell starts with Wave integration │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**WSH (Wave Shell Extensions) Details:** + +**What is WSH?** +- Binary program (`wsh`) that runs on remote hosts +- Provides RPC services for Wave Terminal +- Written in Go, cross-platform +- Versioned to match Wave Terminal version + +**WSH Components:** +1. **wsh version**: Reports installed version +2. **wsh connserver**: Long-running RPC server + - Handles file operations + - Executes commands + - Provides remote state information + - Communicates over domain socket + +**WSH Installation Process:** +1. Check if wsh is installed: Run `wsh version` +2. If not installed: Detect platform with `uname -sm` +3. Get appropriate binary from local cache +4. Copy to remote: `~/.waveterm/bin/wsh` +5. Set executable permissions +6. Restart connection process + +**With vs Without WSH:** +- **With WSH**: Full RPC support, better integration, file sync +- **Without WSH**: Basic shell only, limited features +- Fallback to no-WSH mode on installation failure + +### WSL Connections + +**Connection Name**: `"wsl://[distro]"` (e.g., `"wsl://Ubuntu"`) + +**Workflow:** +``` +1. GetWslConn(distroName) - Get/create WslConn +2. conn.Connect(ctx) - Start connection +3. OpenDomainSocketListener() - Set socket path (no actual listener) +4. StartConnServer() - Launch "wsh connserver" via wsl.exe +5. Install/update WSH if needed (similar to SSH) +6. Status: "connected" +7. StartWslShellProc() - Create shell process in WSL +``` + +**Key Differences from SSH:** +- Uses `wsl.exe` command-line tool +- No network connection overhead +- Predetermined domain socket path +- Simpler authentication (inherited from Windows) + +## Token Swap System + +**Purpose**: Pass connection-specific environment variables to shell processes + +**Implementation:** [`shellutil.TokenSwapEntry`](../pkg/util/shellutil/) + +**Flow:** +1. ShellController creates swap token before starting process +2. Token contains: + - Socket name for RPC + - JWT token for authentication + - RPC context (TabId, BlockId, Conn) + - Custom environment variables +3. Token stored in global swap map +4. Shell process receives token ID via environment +5. Shell integration scripts swap token for actual values +6. Token removed from map after use + +**Purpose:** +- Avoid exposing JWT tokens in process listings +- Enable shell integration without hardcoded values +- Support multiple shells on same connection + +## Error Handling and Recovery + +### Connection Failures + +**SSH Connection Errors:** +- Authentication failure → Prompt user (password, passphrase) +- Host key mismatch → Prompt for verification +- Network timeout → Status: "error", display error message +- ProxyJump failure → Error shows which jump host failed + +**Recovery Mechanisms:** +- [`conn.Reconnect(ctx)`](../pkg/remote/conncontroller/) - Close and re-establish connection +- [`conn.WaitForConnect(ctx)`](../pkg/remote/conncontroller/) - Block until connected +- Automatic fallback to no-WSH mode on installation failure + +### Process Failures + +**Shell Process Errors:** +- Process crash → WaitErr contains exit code +- PTY failure → Captured in error message +- I/O errors → Logged and surfaced to user + +**Cleanup:** +- [`ShellProc.Close()`](../pkg/shellexec/shellexec.go:56) - Graceful then forceful kill +- [`SSHConn.close_nolock()`](../pkg/remote/conncontroller/conncontroller.go:167) - Cleanup all resources +- [`deleteController()`](../pkg/blockcontroller/blockcontroller.go:101) - Remove from registry + +## Configuration Integration + +### Connection Configuration + +**Source:** [`pkg/wconfig/`](../pkg/wconfig/) + +**Per-Connection Settings:** +- `conn:wshenabled` - Enable/disable WSH +- `conn:wshpath` - Custom WSH binary path +- `conn:shellpath` - Custom shell path + +**Global Settings:** +- `conn:askbeforewshinstall` - Prompt before WSH installation +- Stored in `~/.waveterm/config/settings.json` +- Per-connection overrides in `~/.waveterm/config/connections.json` + +### SSH Configuration + +**Source:** `~/.ssh/config` + +**Supported Directives:** +- `Host` - Connection matching +- `HostName` - Target hostname +- `Port` - SSH port +- `User` - Username +- `IdentityFile` - Private key paths +- `ProxyJump` - Jump host specification +- `UserKnownHostsFile` - Known hosts file +- `GlobalKnownHostsFile` - System known hosts +- `AddKeysToAgent` - Add keys to SSH agent + +**Library:** [`github.com/kevinburke/ssh_config`](https://github.com/kevinburke/ssh_config) + +## Thread Safety + +### Synchronization Patterns + +**SSHConn/WslConn:** +```go +conn.Lock.Lock() +defer conn.Lock.Unlock() +// ... modify connection state +``` + +**Atomic Flags:** +```go +conn.WshEnabled.Load() // Read WSH enabled status +conn.WshEnabled.Store(v) // Update atomically +``` + +**Controller Registry:** +```go +registryLock.RLock() // Read lock for lookups +registryLock.Lock() // Write lock for modifications +``` + +**ShellProc Completion:** +```go +sp.CloseOnce.Do(func() { // Ensure single execution + sp.WaitErr = waitErr + close(sp.DoneCh) // Signal completion +}) +``` + +## Event System Integration + +### Connection Events + +**Published via:** [`pkg/wps/`](../pkg/wps/) (Wave Publish/Subscribe) + +**Event Types:** +- `Event_ConnChange` - Connection status changed +- `Event_ControllerStatus` - Block controller status update +- `Event_BlockFile` - Block file operation (terminal output) + +**Example:** +```go +wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_ConnChange, + Scopes: []string{fmt.Sprintf("connection:%s", connName)}, + Data: connStatus, +}) +``` + +**Frontend Integration:** +- Events received via WebSocket +- Connection status updates UI +- Real-time terminal output streaming + +## Summary of Responsibilities + +| Component | Responsibilities | +|-----------|-----------------| +| **blockcontroller/** | Block lifecycle, controller registry, connection coordination | +| **shellcontroller** | Shell process management, ConnUnion abstraction, I/O handling | +| **conncontroller/** | SSH connection lifecycle, WSH management, domain socket setup | +| **wslconn/** | WSL connection lifecycle, parallel to SSH but for WSL | +| **sshclient.go** | Low-level SSH: auth, known_hosts, ProxyJump | +| **shellexec/** | Process execution abstraction, PTY management | +| **genconn/** | Generic command execution interface | +| **shellutil/** | Shell detection, integration files, environment setup | + +## Key Design Principles + +1. **Layered Architecture**: Clear separation between block management, connection management, and process execution + +2. **Connection Abstraction**: ConnUnion pattern allows uniform handling of Local/SSH/WSL + +3. **WSH Optional**: System works with and without Wave Shell Extensions, degrading gracefully + +4. **Thread Safety**: Defensive locking, atomic flags, singleton patterns prevent race conditions + +5. **Error Recovery**: Multiple retry mechanisms, fallback modes, user prompts for resolution + +6. **Configuration Hierarchy**: Global → Connection-Specific → Runtime overrides + +7. **Event-Driven Updates**: Real-time status updates via pub/sub system + +8. **User Interaction**: Non-blocking prompts for passwords, confirmations, installations + +This architecture provides a robust foundation for Wave Terminal's multi-environment shell capabilities, with clear extension points for adding new connection types or capabilities. \ No newline at end of file diff --git a/aiprompts/contextmenu.md b/aiprompts/contextmenu.md new file mode 100644 index 000000000..7447c4f1d --- /dev/null +++ b/aiprompts/contextmenu.md @@ -0,0 +1,141 @@ +# Context Menu Quick Reference + +This guide provides a quick overview of how to create and display a context menu using our system. + +--- + +## ContextMenuItem Type + +Define each menu item using the `ContextMenuItem` type: + +```ts +type ContextMenuItem = { + label?: string; + type?: "separator" | "normal" | "submenu" | "checkbox" | "radio"; + role?: string; // Electron role (optional) + click?: () => void; // Callback for item selection (not needed if role is set) + submenu?: ContextMenuItem[]; // For nested menus + checked?: boolean; // For checkbox or radio items + visible?: boolean; + enabled?: boolean; + sublabel?: string; +}; +``` + +--- + +## Import and Show the Menu + +Import the context menu module: + +```ts +import { ContextMenuModel } from "@/app/store/contextmenu"; +``` + +To display the context menu, call: + +```ts +ContextMenuModel.showContextMenu(menu, event); +``` + +- **menu**: An array of `ContextMenuItem`. +- **event**: The mouse event that triggered the context menu (typically from an onContextMenu handler). + +--- + +## Basic Example + +A simple context menu with a separator: + +```ts +const menu: ContextMenuItem[] = [ + { + label: "New File", + click: () => { + /* create a new file */ + }, + }, + { + label: "New Folder", + click: () => { + /* create a new folder */ + }, + }, + { type: "separator" }, + { + label: "Rename", + click: () => { + /* rename item */ + }, + }, +]; + +ContextMenuModel.showContextMenu(menu, e); +``` + +--- + +## Example with Submenu and Checkboxes + +Toggle settings using a submenu with checkbox items: + +```ts +const isClearOnStart = true; // Example setting + +const menu: ContextMenuItem[] = [ + { + label: "Clear Output On Restart", + submenu: [ + { + label: "On", + type: "checkbox", + checked: isClearOnStart, + click: () => { + // Set the config to enable clear on restart + }, + }, + { + label: "Off", + type: "checkbox", + checked: !isClearOnStart, + click: () => { + // Set the config to disable clear on restart + }, + }, + ], + }, +]; + +ContextMenuModel.showContextMenu(menu, e); +``` + +--- + +## Editing a Config File Example + +Open a configuration file (e.g., `widgets.json`) in preview mode: + +```ts +{ + label: "Edit widgets.json", + click: () => { + fireAndForget(async () => { + const path = `${getApi().getConfigDir()}/widgets.json`; + const blockDef: BlockDef = { + meta: { view: "preview", file: path }, + }; + await createBlock(blockDef, false, true); + }); + }, +} +``` + +--- + +## Summary + +- **Menu Definition**: Use the `ContextMenuItem` type. +- **Actions**: Use `click` for actions; use `submenu` for nested options. +- **Separators**: Use `type: "separator"` to group items. +- **Toggles**: Use `type: "checkbox"` or `"radio"` with the `checked` property. +- **Displaying**: Use `ContextMenuModel.showContextMenu(menu, event)` to render the menu. diff --git a/aiprompts/fe-conn-arch.md b/aiprompts/fe-conn-arch.md new file mode 100644 index 000000000..eafb46cea --- /dev/null +++ b/aiprompts/fe-conn-arch.md @@ -0,0 +1,1007 @@ +# Wave Terminal Frontend Connection Architecture + +## Overview + +The frontend connection architecture provides a reactive interface for managing and interacting with connections (local, SSH, WSL, S3). It follows a unidirectional data flow pattern where the backend manages connection state, the frontend observes this state through Jotai atoms, and user interactions trigger backend operations via RPC commands. + +## Architecture Pattern + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ User Interface │ +│ - ConnectionButton (displays status) │ +│ - ChangeConnectionBlockModal (connection picker) │ +│ - ConnStatusOverlay (error states) │ +└─────────────────────────────────────────────────────────────────┘ + ↕ +┌─────────────────────────────────────────────────────────────────┐ +│ Jotai Reactive State │ +│ - ConnStatusMapAtom (connection statuses) │ +│ - View Model Atoms (derived connection state) │ +│ - Block Metadata (connection selection) │ +└─────────────────────────────────────────────────────────────────┘ + ↕ +┌─────────────────────────────────────────────────────────────────┐ +│ RPC Commands │ +│ - ConnListCommand (list connections) │ +│ - ConnEnsureCommand (ensure connected) │ +│ - ConnConnectCommand/ConnDisconnectCommand │ +│ - SetMetaCommand (change block connection) │ +│ - ControllerInputCommand (send data to shell) │ +└─────────────────────────────────────────────────────────────────┘ + ↕ +┌─────────────────────────────────────────────────────────────────┐ +│ Backend (see conn-arch.md) │ +│ - Connection Controllers (SSHConn, WslConn) │ +│ - Block Controllers (ShellController) │ +│ - Shell Process Execution │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Key Components + +### 1. Connection State Management ([`frontend/app/store/global.ts`](../frontend/app/store/global.ts)) + +**ConnStatusMapAtom** +```typescript +const ConnStatusMapAtom = atom(new Map>()) +``` + +- Global registry of connection status atoms +- One atom per connection (keyed by connection name) +- Backend updates status via wave events +- Frontend components subscribe to individual connection atoms + +**getConnStatusAtom()** +```typescript +function getConnStatusAtom(connName: string): PrimitiveAtom +``` + +- Retrieves or creates status atom for a connection +- Returns cached atom if exists +- Creates new atom initialized to default if needed +- Used by view models to track their connection + +**ConnStatus Structure** +```typescript +interface ConnStatus { + status: "init" | "connecting" | "connected" | "disconnected" | "error" + connection: string // Connection name + connected: boolean // Is currently connected + activeconnnum: number // Color assignment number (1-8) + wshenabled: boolean // WSH available on this connection + error?: string // Error message if status is "error" + wsherror?: string // WSH-specific error +} +``` + +**allConnStatusAtom** +```typescript +const allConnStatusAtom = atom((get) => { + const connStatusMap = get(ConnStatusMapAtom) + const connStatuses = Array.from(connStatusMap.values()).map((atom) => get(atom)) + return connStatuses +}) +``` + +- Provides array of all connection statuses +- Used by connection modal to display all available connections +- Automatically updates when any connection status changes + +### 2. Connection Button UI ([`frontend/app/block/blockutil.tsx`](../frontend/app/block/blockutil.tsx)) + +**ConnectionButton Component** + +```typescript +export const ConnectionButton = React.memo( + React.forwardRef( + ({ connection, changeConnModalAtom }, ref) => { + const connStatusAtom = getConnStatusAtom(connection) + const connStatus = jotai.useAtomValue(connStatusAtom) + // ... renders connection status with colored icon + } + ) +) +``` + +**Responsibilities:** +- Displays connection name and status icon +- Color-codes connections (8 colors, cycling) +- Shows visual states: + - **Local**: Laptop icon (grey) + - **Connecting**: Animated dots (yellow/warning) + - **Connected**: Arrow icon (colored by activeconnnum) + - **Error**: Slashed arrow icon (red) + - **Disconnected**: Slashed arrow icon (grey) +- Opens connection modal on click + +**Color Assignment:** +```typescript +function computeConnColorNum(connStatus: ConnStatus): number { + const connColorNum = (connStatus?.activeconnnum ?? 1) % NumActiveConnColors + return connColorNum == 0 ? NumActiveConnColors : connColorNum +} +``` + +- Backend assigns `activeconnnum` sequentially +- Frontend cycles through 8 CSS color variables +- `var(--conn-icon-color-1)` through `var(--conn-icon-color-8)` + +### 3. Connection Selection Modal ([`frontend/app/modals/conntypeahead.tsx`](../frontend/app/modals/conntypeahead.tsx)) + +**ChangeConnectionBlockModal Component** + +**Data Fetching:** +```typescript +useEffect(() => { + if (!changeConnModalOpen) return + + // Fetch available connections + RpcApi.ConnListCommand(TabRpcClient, { timeout: 2000 }) + .then(setConnList) + + RpcApi.WslListCommand(TabRpcClient, { timeout: 2000 }) + .then(setWslList) + + RpcApi.ConnListAWSCommand(TabRpcClient, { timeout: 2000 }) + .then(setS3List) +}, [changeConnModalOpen]) +``` + +**Connection Change Handler:** +```typescript +const changeConnection = async (connName: string) => { + // Update block metadata with new connection + await RpcApi.SetMetaCommand(TabRpcClient, { + oref: WOS.makeORef("block", blockId), + meta: { + connection: connName, + file: newFile, // Reset file path for new connection + "cmd:cwd": null // Clear working directory + } + }) + + // Ensure connection is established + await RpcApi.ConnEnsureCommand(TabRpcClient, { + connname: connName, + logblockid: blockId + }, { timeout: 60000 }) +} +``` + +**Suggestion Categories:** +1. **Local Connections** + - Local machine (`""` or `"local:"`) + - Git Bash (Windows only: `"local:gitbash"`) + - WSL distros (`"wsl://Ubuntu"`, etc.) + +2. **Remote Connections** (SSH) + - User-configured SSH connections + - Format: `"user@host"` or `"user@host:port"` + - Filtered by `display:hidden` config + +3. **S3 Connections** (optional) + - AWS S3 profiles + - Format: `"aws:profile-name"` + +4. **Actions** + - Reconnect (if disconnected/error) + - Disconnect (if connected) + - Edit Connections (opens config editor) + - New Connection (creates new SSH config) + +**Filtering Logic:** +```typescript +function filterConnections( + connList: Array, + connSelected: string, + fullConfig: FullConfigType, + filterOutNowsh: boolean +): Array { + const connectionsConfig = fullConfig.connections + return connList.filter((conn) => { + const hidden = connectionsConfig?.[conn]?.["display:hidden"] ?? false + const wshEnabled = connectionsConfig?.[conn]?.["conn:wshenabled"] ?? true + return conn.includes(connSelected) && + !hidden && + (wshEnabled || !filterOutNowsh) + }) +} +``` + +### 4. Connection Status Overlay ([`frontend/app/block/blockframe.tsx`](../frontend/app/block/blockframe.tsx)) + +**ConnStatusOverlay Component** + +Displays over block content when: +- Connection is disconnected or in error state +- WSH installation/update errors occur +- Not in layout mode (Ctrl+Shift held) +- Connection modal is not open + +**Features:** +- Shows connection status text +- Displays error messages (scrollable) +- Reconnect button (for disconnected/error) +- "Always disable wsh" button (for WSH errors) +- Adaptive layout based on width + +**Handlers:** +```typescript +// Reconnect to failed connection +const handleTryReconnect = () => { + RpcApi.ConnConnectCommand(TabRpcClient, { + host: connName, + logblockid: nodeModel.blockId + }, { timeout: 60000 }) +} + +// Disable WSH for this connection +const handleDisableWsh = async () => { + await RpcApi.SetConnectionsConfigCommand(TabRpcClient, { + host: connName, + metamaptype: { "conn:wshenabled": false } + }) +} +``` + +### 5. View Model Integration + +View models integrate connection state into their reactive data flow: + +#### Terminal View Model ([`frontend/app/view/term/term-model.ts`](../frontend/app/view/term/term-model.ts)) + +```typescript +class TermViewModel implements ViewModel { + // Connection management flag + manageConnection = atom((get) => { + const termMode = get(this.termMode) + if (termMode == "vdom") return false // VDOM mode doesn't show conn button + + const isCmd = get(this.isCmdController) + if (isCmd) return false // Cmd controller doesn't manage connections + + return true // Standard terminals show connection button + }) + + // Connection status for this block + connStatus = atom((get) => { + const blockData = get(this.blockAtom) + const connName = blockData?.meta?.connection + const connAtom = getConnStatusAtom(connName) + return get(connAtom) + }) + + // Filter connections without WSH + filterOutNowsh = atom(false) +} +``` + +**End Icon Button Logic:** +```typescript +endIconButtons = atom((get) => { + const connStatus = get(this.connStatus) + const shellProcStatus = get(this.shellProcStatus) + + // Only show restart button if connected + if (connStatus?.status != "connected") { + return [] + } + + // Show appropriate icon based on shell state + if (shellProcStatus == "init") { + return [{ icon: "play", title: "Click to Start Shell" }] + } else if (shellProcStatus == "running") { + return [{ icon: "refresh", title: "Shell Running. Click to Restart" }] + } else if (shellProcStatus == "done") { + return [{ icon: "refresh", title: "Shell Exited. Click to Restart" }] + } +}) +``` + +#### Preview View Model ([`frontend/app/view/preview/preview-model.tsx`](../frontend/app/view/preview/preview-model.tsx)) + +```typescript +class PreviewModel implements ViewModel { + // Always manages connection + manageConnection = atom(true) + + // Connection status + connStatus = atom((get) => { + const blockData = get(this.blockAtom) + const connName = blockData?.meta?.connection + const connAtom = getConnStatusAtom(connName) + return get(connAtom) + }) + + // Filter out connections without WSH (file ops require WSH) + filterOutNowsh = atom(true) + + // Ensure connection before operations + connection = atom>(async (get) => { + const connName = get(this.blockAtom)?.meta?.connection + try { + await RpcApi.ConnEnsureCommand(TabRpcClient, { + connname: connName + }, { timeout: 60000 }) + globalStore.set(this.connectionError, "") + } catch (e) { + globalStore.set(this.connectionError, e as string) + } + return connName + }) +} +``` + +**File Operations Over Connection:** +```typescript +// Reads file from remote/local connection +statFile = atom>(async (get) => { + const fileName = get(this.metaFilePath) + const path = await this.formatRemoteUri(fileName, get) + + return await RpcApi.FileInfoCommand(TabRpcClient, { + info: { path } + }) +}) + +fullFile = atom>(async (get) => { + const fileName = get(this.metaFilePath) + const path = await this.formatRemoteUri(fileName, get) + + return await RpcApi.FileReadCommand(TabRpcClient, { + info: { path } + }) +}) +``` + +### 6. Block Controller Integration + +**View models do NOT directly manage shell processes.** They interact with block controllers via RPC: + +**Starting a Shell:** +```typescript +// User clicks restart button in terminal +forceRestartController() { + // Backend handles connection verification and process startup + RpcApi.ControllerRestartCommand(TabRpcClient, { + blockid: this.blockId, + force: true + }) +} +``` + +**Sending Input to Shell:** +```typescript +sendDataToController(data: string) { + const b64data = stringToBase64(data) + RpcApi.ControllerInputCommand(TabRpcClient, { + blockid: this.blockId, + inputdata64: b64data + }) +} +``` + +**Backend Block Controller Flow:** +1. Frontend calls `ControllerRestartCommand` +2. Backend `ShellController.Run()` starts +3. `CheckConnStatus()` verifies connection is ready +4. If not connected, triggers connection attempt +5. Once connected, `setupAndStartShellProcess()` +6. `getConnUnion()` retrieves appropriate connection (Local/SSH/WSL) +7. `StartLocalShellProc()`, `StartRemoteShellProc()`, or `StartWslShellProc()` +8. Process I/O managed by `manageRunningShellProcess()` + +## Connection Configuration + +### Hierarchical Configuration System + +Wave uses a three-level config hierarchy for connections: + +1. **Global Settings** (`settings`) +2. **Connection-Level Config** (`connections[connName]`) +3. **Block-Level Overrides** (`block.meta`) + +**Override Resolution:** +```typescript +function getOverrideConfigAtom(blockId: string, key: T): Atom { + return atom((get) => { + // 1. Check block metadata + const metaKeyVal = get(getBlockMetaKeyAtom(blockId, key)) + if (metaKeyVal != null) return metaKeyVal + + // 2. Check connection config + const connName = get(getBlockMetaKeyAtom(blockId, "connection")) + const connConfigKeyVal = get(getConnConfigKeyAtom(connName, key)) + if (connConfigKeyVal != null) return connConfigKeyVal + + // 3. Fall back to global settings + const settingsVal = get(getSettingsKeyAtom(key)) + return settingsVal ?? null + }) +} +``` + +### Common Connection Settings + +**Connection Keywords** (apply to specific connections): +- `conn:wshenabled` - Enable/disable WSH for this connection +- `conn:wshpath` - Custom WSH binary path +- `display:hidden` - Hide connection from selector +- `display:order` - Sort order in connection list +- `term:fontsize` - Font size for terminals on this connection +- `term:theme` - Color theme for terminals on this connection + +**Example Usage in View Models:** +```typescript +// Font size with connection override +fontSizeAtom = atom((get) => { + const blockData = get(this.blockAtom) + const connName = blockData?.meta?.connection + const fullConfig = get(atoms.fullConfigAtom) + + // Check: block meta > connection config > global settings + const fontSize = blockData?.meta?.["term:fontsize"] ?? + fullConfig?.connections?.[connName]?.["term:fontsize"] ?? + get(getSettingsKeyAtom("term:fontsize")) ?? + 12 + + return boundNumber(fontSize, 4, 64) +}) +``` + +## RPC Interface + +### Connection Management Commands + +**ConnListCommand** +```typescript +ConnListCommand(client: RpcClient): Promise +``` +- Returns list of configured SSH connection names +- Used by connection modal to populate remote connections +- Filters by `display:hidden` config on frontend + +**WslListCommand** +```typescript +WslListCommand(client: RpcClient): Promise +``` +- Returns list of installed WSL distribution names +- Windows only (silently fails on other platforms) +- Connection names formatted as `wsl://[distro]` + +**ConnListAWSCommand** +```typescript +ConnListAWSCommand(client: RpcClient): Promise +``` +- Returns list of AWS profile names from config +- Used for S3 preview connections +- Connection names formatted as `aws:[profile]` + +**ConnEnsureCommand** +```typescript +ConnEnsureCommand( + client: RpcClient, + data: { connname: string, logblockid?: string } +): Promise +``` +- Ensures connection is in "connected" state +- Triggers connection if not already connected +- Waits for connection to complete or timeout +- Used before file operations and by view models + +**ConnConnectCommand** +```typescript +ConnConnectCommand( + client: RpcClient, + data: { host: string, logblockid?: string } +): Promise +``` +- Explicitly connects to specified connection +- Used by "Reconnect" action in overlay +- Returns when connection succeeds or fails + +**ConnDisconnectCommand** +```typescript +ConnDisconnectCommand( + client: RpcClient, + connName: string +): Promise +``` +- Disconnects active connection +- Used by "Disconnect" action in connection modal +- Closes all shells/processes on that connection + +**SetMetaCommand** +```typescript +SetMetaCommand( + client: RpcClient, + data: { + oref: string, // WaveObject reference + meta: MetaType // Metadata updates + } +): Promise +``` +- Updates block metadata (including connection) +- Used when changing block's connection +- Triggers backend to switch connection context + +**SetConnectionsConfigCommand** +```typescript +SetConnectionsConfigCommand( + client: RpcClient, + data: { + host: string, // Connection name + metamaptype: any // Config updates + } +): Promise +``` +- Updates connection-level configuration +- Used to disable WSH (`conn:wshenabled: false`) +- Persists to config file + +### File Operations (Connection-Aware) + +**FileInfoCommand** +```typescript +FileInfoCommand( + client: RpcClient, + data: { info: { path: string } } +): Promise +``` +- Gets file metadata (size, type, permissions, etc.) +- Path format: `[connName]:[filepath]` (e.g., `user@host:~/file.txt`) +- Uses connection's WSH for remote files + +**FileReadCommand** +```typescript +FileReadCommand( + client: RpcClient, + data: { info: { path: string } } +): Promise +``` +- Reads file content as base64 +- Supports streaming for large files +- Remote files read via connection's WSH + +### Controller Commands (Indirect Connection Usage) + +**ControllerInputCommand** +```typescript +ControllerInputCommand( + client: RpcClient, + data: { blockid: string, inputdata64: string } +): Promise +``` +- Sends input to block's controller (shell) +- Controller uses block's connection for execution +- Base64-encoded to handle binary data + +**ControllerRestartCommand** +```typescript +ControllerRestartCommand( + client: RpcClient, + data: { blockid: string, force?: boolean } +): Promise +``` +- Restarts block's controller +- Backend checks connection status before starting +- If not connected, triggers connection first + +## Event-Driven Updates + +### Wave Event Subscriptions + +**Connection Status Updates:** +```typescript +waveEventSubscribe({ + eventType: "connstatus", + handler: (event) => { + const status: ConnStatus = event.data + updateConnStatusAtom(status.connection, status) + } +}) +``` +- Backend emits connection status changes +- Frontend updates corresponding atom +- All subscribed components re-render automatically + +**Configuration Updates:** +```typescript +waveEventSubscribe({ + eventType: "config", + handler: (event) => { + const fullConfig = event.data.fullconfig + globalStore.set(atoms.fullConfigAtom, fullConfig) + } +}) +``` +- Backend watches config files for changes +- Pushes updates to all connected frontends +- Connection configuration changes take effect immediately + +## Data Flow Patterns + +### Pattern 1: Changing Block Connection + +``` +User Action: Click connection button → select new connection + ↓ + ChangeConnectionBlockModal.changeConnection() + ↓ + RpcApi.SetMetaCommand({ connection: newConn }) + ↓ + Backend updates block metadata → emits waveobj:update + ↓ + Frontend WOS updates blockAtom + ↓ + View model connStatus atom recomputes + ↓ + ConnectionButton re-renders with new connection + ↓ + RpcApi.ConnEnsureCommand() ensures connected + ↓ + Backend triggers connection if needed + ↓ + Backend emits connstatus events as connection progresses + ↓ + Frontend updates ConnStatus atom ("connecting" → "connected") + ↓ + ConnectionButton shows connecting animation → connected state +``` + +### Pattern 2: Shell Process Lifecycle + +``` +User Action: Press Enter in disconnected terminal + ↓ + View model detects shellProcStatus == "init" or "done" + ↓ + forceRestartController() called + ↓ + RpcApi.ControllerRestartCommand() + ↓ + Backend ShellController.Run() starts + ↓ + CheckConnStatus() verifies connection + ↓ + If not connected: trigger connection + ↓ + (Frontend shows ConnStatusOverlay with "connecting") + ↓ + Connection succeeds → WSH available + ↓ + setupAndStartShellProcess() + ↓ + StartRemoteShellProc() with connection's SSH client + ↓ + Backend emits controllerstatus event + ↓ + Frontend updates shellProcStatus atom + ↓ + View model endIconButtons recomputes (restart button) + ↓ + Terminal ready for input +``` + +### Pattern 3: File Preview Over Connection + +``` +User Action: Open preview block with file path + ↓ + PreviewModel initialized with file path + ↓ + connection atom ensures connection + ↓ + RpcApi.ConnEnsureCommand(connName) + ↓ + Backend establishes connection if needed + ↓ + (Frontend shows ConnStatusOverlay if connecting) + ↓ + Connection ready + ↓ + statFile atom triggers FileInfoCommand + ↓ + Backend routes to connection's WSH + ↓ + WSH executes stat on remote file + ↓ + FileInfo returned to frontend + ↓ + PreviewModel determines if text/binary/streaming + ↓ + fullFile atom triggers FileReadCommand + ↓ + Backend streams file via WSH + ↓ + File content displayed in preview +``` + +## Connection Types and Behaviors + +### Local Connection + +**Connection Names:** +- `""` (empty string) +- `"local"` +- `"local:"` +- `"local:gitbash"` (Windows only) + +**Frontend Behavior:** +- No connection modal interaction needed +- ConnectionButton shows laptop icon (grey) +- No ConnStatusOverlay shown (always "connected") +- File paths used directly without connection prefix +- Shell processes spawn locally via `os/exec` + +**View Model Configuration:** +```typescript +connName = "" // or "local" or "local:gitbash" +connStatus = { + status: "connected", + connection: "", + connected: true, + activeconnnum: 0, // No color assignment + wshenabled: true // Local WSH always available +} +``` + +### SSH Connection + +**Connection Names:** +- Format: `"user@host"`, `"user@host:port"`, or config name +- Examples: `"ubuntu@192.168.1.10"`, `"myserver"`, `"deploy@prod:2222"` + +**Frontend Behavior:** +- ConnectionButton shows arrow icon with color +- Color cycles through 8 colors based on `activeconnnum` +- ConnStatusOverlay shown during connecting/error states +- File paths prefixed with connection: `user@host:~/file.txt` +- Modal allows reconnect/disconnect actions + +**Connection States:** +```typescript +// Connecting +connStatus = { + status: "connecting", + connection: "user@host", + connected: false, + activeconnnum: 3, + wshenabled: false // Not yet determined +} + +// Connected with WSH +connStatus = { + status: "connected", + connection: "user@host", + connected: true, + activeconnnum: 3, + wshenabled: true +} + +// Connected without WSH +connStatus = { + status: "connected", + connection: "user@host", + connected: true, + activeconnnum: 3, + wshenabled: false, + wsherror: "wsh installation failed: permission denied" +} + +// Error +connStatus = { + status: "error", + connection: "user@host", + connected: false, + activeconnnum: 3, + wshenabled: false, + error: "ssh: connection refused" +} +``` + +**WSH Errors:** +- Shown in ConnStatusOverlay +- "always disable wsh" button sets `conn:wshenabled: false` +- Terminal still works without WSH (limited features) +- Preview requires WSH (shows error if unavailable) + +### WSL Connection + +**Connection Names:** +- Format: `"wsl://[distro]"` +- Examples: `"wsl://Ubuntu"`, `"wsl://Debian"`, `"wsl://Ubuntu-20.04"` + +**Frontend Behavior:** +- Similar to SSH (colored arrow icon) +- Listed under "Local" section in modal +- No authentication prompts +- File paths: `wsl://Ubuntu:~/file.txt` + +**Backend Differences:** +- Uses `wsl.exe` instead of SSH +- No network overhead +- Predetermined domain socket path +- Simpler error handling + +### S3 Connection (Preview Only) + +**Connection Names:** +- Format: `"aws:[profile]"` +- Examples: `"aws:default"`, `"aws:production"` + +**Frontend Behavior:** +- Database icon (accent color) +- Only available in Preview view +- No shell/terminal support +- File paths: `aws:profile:/bucket/key` + +**View Model Settings:** +```typescript +// Terminal: S3 not shown +showS3 = atom(false) + +// Preview: S3 shown +showS3 = atom(true) +``` + +## Error Handling + +### Connection Errors + +**Authentication Failures:** +- Backend prompts for credentials via `userinput` events +- Frontend shows UserInputModal +- User enters password/passphrase +- Connection retries automatically + +**Network Errors:** +- ConnStatus.status becomes "error" +- ConnStatus.error contains message +- ConnStatusOverlay displays error +- "Reconnect" button triggers `ConnConnectCommand` + +**WSH Installation Errors:** +- ConnStatus.wsherror contains message +- ConnStatusOverlay shows separate WSH error section +- Options: + - Dismiss error (temporary) + - "always disable wsh" (permanent config change) + +### View Model Error Handling + +**Terminal View:** +```typescript +// Shell won't start if connection failed +endIconButtons = atom((get) => { + const connStatus = get(this.connStatus) + if (connStatus?.status != "connected") { + return [] // Hide restart button + } + // ... show restart button +}) + +// ConnStatusOverlay blocks terminal interaction +``` + +**Preview View:** +```typescript +// File operations return errors +errorMsgAtom = atom(null) as PrimitiveAtom + +statFile = atom(async (get) => { + try { + const fileInfo = await RpcApi.FileInfoCommand(...) + return fileInfo + } catch (e) { + globalStore.set(this.errorMsgAtom, { + status: "File Read Failed", + text: `${e}` + }) + throw e + } +}) + +// Error displayed in preview content area +``` + +## Best Practices + +### For View Model Authors + +1. **Use Connection Atoms:** + ```typescript + connStatus = atom((get) => { + const blockData = get(this.blockAtom) + const connName = blockData?.meta?.connection + return get(getConnStatusAtom(connName)) + }) + ``` + +2. **Check Connection Before Operations:** + ```typescript + if (connStatus?.status != "connected") { + return // Don't attempt operation + } + ``` + +3. **Use ConnEnsureCommand for File Ops:** + ```typescript + await RpcApi.ConnEnsureCommand(TabRpcClient, { + connname: connName, + logblockid: blockId // For better logging + }, { timeout: 60000 }) + ``` + +4. **Set manageConnection Appropriately:** + ```typescript + // Show connection button for views that need connections + manageConnection = atom(true) + + // Hide for views that don't use connections + manageConnection = atom(false) + ``` + +5. **Use filterOutNowsh for WSH Requirements:** + ```typescript + // Filter connections without WSH (file ops, etc.) + filterOutNowsh = atom(true) + + // Allow all connections (basic shell) + filterOutNowsh = atom(false) + ``` + +### For RPC Command Usage + +1. **Always Handle Errors:** + ```typescript + try { + await RpcApi.ConnConnectCommand(...) + } catch (e) { + console.error("Connection failed:", e) + // Update UI to show error + } + ``` + +2. **Use Appropriate Timeouts:** + ```typescript + // Connection operations: longer timeout + { timeout: 60000 } // 60 seconds + + // List operations: shorter timeout + { timeout: 2000 } // 2 seconds + ``` + +3. **Batch Related Operations:** + ```typescript + // Good: Single SetMetaCommand with all changes + await RpcApi.SetMetaCommand(TabRpcClient, { + oref: blockRef, + meta: { + connection: newConn, + file: newPath, + "cmd:cwd": null + } + }) + + // Bad: Multiple SetMetaCommand calls + ``` + +## Summary + +The frontend connection architecture is **reactive and declarative**: + +1. **Backend owns connection state** - All connection management happens in Go +2. **Frontend observes state** - Jotai atoms mirror backend state +3. **User actions trigger backend** - RPC commands initiate backend operations +4. **Events flow back to frontend** - Backend pushes updates via wave events +5. **View models isolate concerns** - Each view manages its own connection needs +6. **Block controllers bridge the gap** - Backend controllers use connections for process execution + +This architecture ensures: +- **Consistency** - Single source of truth (backend) +- **Reactivity** - UI updates automatically with state changes +- **Separation** - Frontend doesn't manage connection lifecycle +- **Flexibility** - Views can easily add connection support +- **Robustness** - Errors handled at appropriate layers \ No newline at end of file diff --git a/aiprompts/focus-layout.md b/aiprompts/focus-layout.md new file mode 100644 index 000000000..7056b5ad3 --- /dev/null +++ b/aiprompts/focus-layout.md @@ -0,0 +1,174 @@ +# Wave Terminal Focus System - Layout State Flow + +This document explains how focus state changes in the layout system propagate through the application to update both the visual focus ring and physical DOM focus. + +## Overview + +When layout operations modify focus state, a straightforward chain of updates occurs: +1. **Visual feedback** - The focus ring updates immediately +2. **Physical DOM focus** - The terminal (or other view) receives actual browser focus + +The system uses local atoms as the source of truth with async persistence to the backend. + +## The Flow + +### 1. Setting Focus in Layout Operations + +Throughout [`layoutTree.ts`](../frontend/layout/lib/layoutTree.ts), operations directly mutate `layoutState.focusedNodeId`: + +```typescript +// Example from insertNode +if (action.magnified) { + layoutState.magnifiedNodeId = action.node.id; + layoutState.focusedNodeId = action.node.id; +} +if (action.focused) { + layoutState.focusedNodeId = action.node.id; +} +``` + +This happens in ~10 places: insertNode, insertNodeAtIndex, deleteNode, focusNode, magnifyNodeToggle, etc. + +### 2. Committing to Local Atom + +The [`LayoutModel.treeReducer()`](../frontend/layout/lib/layoutModel.ts:547) commits changes: + +```typescript +treeReducer(action: LayoutTreeAction, setState = true): boolean { + // Mutate tree state + focusNode(this.treeState, action); + + if (setState) { + this.updateTree(); // Compute leafOrder, etc. + this.setter(this.localTreeStateAtom, { ...this.treeState }); // Sync update + this.persistToBackend(); // Async persistence + } +} +``` + +The key is `{ ...this.treeState }` creates a new object reference, triggering Jotai reactivity. + +### 3. Derived Atoms Recalculate + +Each block's `NodeModel` has an `isFocused` atom: + +```typescript +isFocused: atom((get) => { + const treeState = get(this.localTreeStateAtom); + const isFocused = treeState.focusedNodeId === nodeid; + const waveAIFocused = get(atoms.waveAIFocusedAtom); + return isFocused && !waveAIFocused; +}) +``` + +When `localTreeStateAtom` updates, all `isFocused` atoms recalculate. Only the matching node returns `true`. + +### 4. React Components Re-render + +**Visual Focus Ring** - Components subscribe to `isFocused`: + +```typescript +const isFocused = useAtomValue(nodeModel.isFocused); +``` + +CSS classes update immediately, showing the focus ring. + +**Physical DOM Focus** - Two-step effect chain: + +```typescript +// Step 1: isFocused → blockClicked +useLayoutEffect(() => { + setBlockClicked(isFocused); +}, [isFocused]); + +// Step 2: blockClicked → physical focus +useLayoutEffect(() => { + if (!blockClicked) return; + setBlockClicked(false); + const focusWithin = focusedBlockId() == nodeModel.blockId; + if (!focusWithin) { + setFocusTarget(); // Calls viewModel.giveFocus() + } +}, [blockClicked, isFocused]); +``` + +The terminal's `giveFocus()` method grants actual browser focus: + +```typescript +giveFocus(): boolean { + if (termMode == "term" && this.termRef?.current?.terminal) { + this.termRef.current.terminal.focus(); + return true; + } + return false; +} +``` + +### 5. Background Persistence + +While the UI updates synchronously, persistence happens asynchronously: + +```typescript +private persistToBackend() { + // Debounced (100ms) to avoid excessive writes + setTimeout(() => { + waveObj.rootnode = this.treeState.rootNode; + waveObj.focusednodeid = this.treeState.focusedNodeId; + waveObj.magnifiednodeid = this.treeState.magnifiedNodeId; + waveObj.leaforder = this.treeState.leafOrder; + this.setter(this.waveObjectAtom, waveObj); + }, 100); +} +``` + +The WaveObject is used purely for persistence (tab restore, uncaching). + +## The Complete Chain + +``` +User action + ↓ +layoutState.focusedNodeId = nodeId + ↓ +setter(localTreeStateAtom, { ...treeState }) + ↓ +isFocused atoms recalculate + ↓ +React re-renders + ↓ +┌────────────────────â”Ŧ────────────────────┐ +│ Visual Ring │ Physical Focus │ +│ (immediate CSS) │ (2-step effect) │ +└────────────────────┴────────────────────┘ + ↓ +persistToBackend() (async, debounced) +``` + +## Key Points + +1. **Local atoms** - `localTreeStateAtom` is the source of truth during runtime +2. **Synchronous updates** - UI changes happen immediately in one React tick +3. **Async persistence** - Backend writes are fire-and-forget with debouncing +4. **Two-step focus** - Separates visual (instant) from physical (coordinated) DOM focus +5. **View delegation** - Each view implements `giveFocus()` for custom focus behavior + +## User-Initiated Focus + +When a user clicks a block: + +1. **`onFocusCapture`** (mousedown) → calls `nodeModel.focusNode()` → visual focus ring appears +2. **`onClick`** → sets `blockClicked = true` → two-step effect chain → physical DOM focus + +This ensures visual feedback is instant while protecting selections. + +## Backend Actions + +On initialization or backend updates, queued actions are processed: + +```typescript +if (initialState.pendingBackendActions?.length) { + fireAndForget(() => this.processPendingBackendActions()); +} +``` + +Backend can queue layout operations (create blocks, etc.) via `PendingBackendActions`. \ No newline at end of file diff --git a/aiprompts/focus.md b/aiprompts/focus.md new file mode 100644 index 000000000..e07f674da --- /dev/null +++ b/aiprompts/focus.md @@ -0,0 +1,236 @@ +# Wave Terminal Focus System + +This document explains how the focus system works in Wave Terminal, particularly for terminal blocks. + +## Overview + +Wave Terminal uses a multi-layered focus system that coordinates between: +- **Layout Focus State**: Jotai atoms tracking which block is focused (`nodeModel.isFocused`) +- **Visual Focus Ring**: CSS styling showing the focused block +- **DOM Focus**: Actual browser focus on interactive elements +- **View-Specific Focus**: Custom focus handling by view models (e.g., XTerm terminal focus) + +## Focus Flow on Block Click + +When you click on a terminal block, this sequence occurs: + +### 1. Click Handler Setup +[`frontend/app/block/block.tsx:219-223`](frontend/app/block/block.tsx:219-223) + +```typescript +const blockModel: BlockComponentModel2 = { + onClick: setBlockClickedTrue, + onFocusCapture: handleChildFocus, + blockRef: blockRef, +}; +``` + +### 2. Click Triggers State Change +[`frontend/app/block/block.tsx:165-167`](frontend/app/block/block.tsx:165-167) + +When clicked, `setBlockClickedTrue` sets the `blockClicked` state to true. + +### 3. useLayoutEffect Responds +[`frontend/app/block/block.tsx:151-163`](frontend/app/block/block.tsx:151-163) + +```typescript +useLayoutEffect(() => { + if (!blockClicked) { + return; + } + setBlockClicked(false); + const focusWithin = focusedBlockId() == nodeModel.blockId; + if (!focusWithin) { + setFocusTarget(); + } + if (!isFocused) { + nodeModel.focusNode(); + } +}, [blockClicked, isFocused]); +``` + +### 4. Focus Target Decision +[`frontend/app/block/block.tsx:211-217`](frontend/app/block/block.tsx:211-217) + +```typescript +const setFocusTarget = useCallback(() => { + const ok = viewModel?.giveFocus?.(); + if (ok) { + return; + } + focusElemRef.current?.focus({ preventScroll: true }); +}, []); +``` + +The `setFocusTarget` function: +1. First attempts to call the view model's `giveFocus()` method +2. If that succeeds (returns true), we're done +3. Otherwise, falls back to focusing a dummy input element + +### 5. Terminal-Specific Focus +[`frontend/app/view/term/term.tsx:414-427`](frontend/app/view/term/term.tsx:414-427) + +```typescript +giveFocus(): boolean { + if (this.searchAtoms && globalStore.get(this.searchAtoms.isOpen)) { + return true; + } + let termMode = globalStore.get(this.termMode); + if (termMode == "term") { + if (this.termRef?.current?.terminal) { + this.termRef.current.terminal.focus(); + return true; + } + } + return false; +} +``` + +The terminal's `giveFocus()` calls XTerm's `terminal.focus()` to grant actual DOM focus. + +## Selection Protection + +A critical feature is that text selections are preserved when clicking within the same block. + +### The Protection Mechanism +[`frontend/app/block/block.tsx:156-158`](frontend/app/block/block.tsx:156-158) + +```typescript +const focusWithin = focusedBlockId() == nodeModel.blockId; +if (!focusWithin) { + setFocusTarget(); +} +``` + +The key is [`focusedBlockId()`](frontend/util/focusutil.ts:48-70) which checks: + +1. **Active Element**: Is there a focused DOM element within this block? +2. **Selection**: Is there a text selection within this block? + +```typescript +export function focusedBlockId(): string { + const focused = document.activeElement; + if (focused instanceof HTMLElement) { + const blockId = findBlockId(focused); + if (blockId) { + return blockId; + } + } + const sel = document.getSelection(); + if (sel && sel.anchorNode && sel.rangeCount > 0 && !sel.isCollapsed) { + let anchor = sel.anchorNode; + if (anchor instanceof Text) { + anchor = anchor.parentElement; + } + if (anchor instanceof HTMLElement) { + const blockId = findBlockId(anchor); + if (blockId) { + return blockId; + } + } + } + return null; +} +``` + +**When making a text selection within a block:** +- `focusWithin` returns true (selection exists in the block) +- `setFocusTarget()` is **skipped** +- Selection is preserved +- Only `nodeModel.focusNode()` is called to update layout state + +## Visual Focus vs DOM Focus + +There's an important separation between visual focus (the focus ring) and actual DOM focus. + +### Visual Focus (Immediate) +[`frontend/app/block/block.tsx:200-209`](frontend/app/block/block.tsx:200-209) + +```typescript +const handleChildFocus = useCallback( + (event: React.FocusEvent) => { + if (!isFocused) { + nodeModel.focusNode(); // Updates layout state immediately + } + }, + [isFocused] +); +``` + +This `onFocusCapture` handler fires on **mousedown** (capture phase), immediately updating the visual focus ring. + +### DOM Focus (On Click Complete) + +The actual DOM focus via `giveFocus()` only happens after click completion, through the onClick → useLayoutEffect path. + +### Selection Example: Two Terminals + +When making a selection in terminal 2 while terminal 1 is focused: + +1. **Mousedown** → `onFocusCapture` fires → `nodeModel.focusNode()` updates focus ring + - Terminal 2 now shows the focus ring + - Layout state updated +2. **Drag** → Selection is made in terminal 2 +3. **Mouseup** → Selection completes +4. **Click handler** → `onClick` fires → `setBlockClickedTrue` → triggers useLayoutEffect +5. **useLayoutEffect** → Checks `focusWithin` (now true because selection exists) +6. **Protected** → Skips `setFocusTarget()`, preserving the selection + +**Result:** Focus ring updates immediately, but DOM focus is only granted after the selection is made, and is protected by the `focusWithin` check. + +## Terminal-Specific Focus Events + +The terminal view has three useEffects that call `giveFocus()`: + +### 1. Search Close +[`frontend/app/view/term/term.tsx:970-974`](frontend/app/view/term/term.tsx:970-974) + +When the search panel closes, focus returns to the terminal. + +### 2. Terminal Recreation +[`frontend/app/view/term/term.tsx:1035-1038`](frontend/app/view/term/term.tsx:1035-1038) + +When a terminal is recreated while focused (e.g., settings change), focus is restored. + +### 3. Mode Switch +[`frontend/app/view/term/term.tsx:1046-1052`](frontend/app/view/term/term.tsx:1046-1052) + +When switching from vdom mode back to term mode, the terminal receives focus. + +## Key Components + +### Block Component +[`frontend/app/block/block.tsx`](frontend/app/block/block.tsx) +- Manages the BlockFull component +- Handles click and focus capture events +- Coordinates between layout focus and DOM focus + +### BlockNodeModel +[`frontend/app/block/blocktypes.ts:7-12`](frontend/app/block/blocktypes.ts:7-12) +```typescript +export interface BlockNodeModel { + blockId: string; + isFocused: Atom; + onClose: () => void; + focusNode: () => void; +} +``` + +### ViewModel Interface +View models can implement `giveFocus(): boolean` to handle focus in a view-specific way. + +### Focus Utilities +[`frontend/util/focusutil.ts`](frontend/util/focusutil.ts) +- `focusedBlockId()`: Determines which block has focus or selection +- `hasSelection()`: Checks if there's an active text selection +- `findBlockId()`: Traverses DOM to find containing block + +## Summary + +The focus system elegantly separates concerns: +- **Visual feedback** updates immediately on mousedown +- **DOM focus** is deferred until after user interaction completes +- **Selections are protected** by checking focus state before granting focus +- **View-specific focus** is delegated to view models via `giveFocus()` + +This design allows for responsive UI (immediate focus ring updates) while preventing disruption of user interactions like text selection. \ No newline at end of file diff --git a/aiprompts/getsetconfigvar.md b/aiprompts/getsetconfigvar.md new file mode 100644 index 000000000..c2ac24411 --- /dev/null +++ b/aiprompts/getsetconfigvar.md @@ -0,0 +1,50 @@ +# Setting and Reading Config Variables + +This document provides a quick reference for updating and reading configuration values in our system. + +--- + +## Setting a Config Variable + +To update a configuration, use the `RpcApi.SetConfigCommand` function. The command takes an object with a key/value pair where the key is the config variable and the value is the new setting. + +**Example:** + +```ts +await RpcApi.SetConfigCommand(TabRpcClient, { "web:defaulturl": url }); +``` + +In this example, `"web:defaulturl"` is the key and `url` is the new value. Use this approach for any config key. + +--- + +## Reading a Config Value + +To read a configuration value, retrieve the corresponding atom using `getSettingsKeyAtom` and then use `globalStore.get` to access its current value. getSettingsKeyAtom returns a jotai Atom. + +**Example:** + +```ts +const configAtom = getSettingsKeyAtom("app:defaultnewblock"); +const configValue = globalStore.get(configAtom) ?? "default value"; +``` + +Here, `"app:defaultnewblock"` is the config key and `"default value"` serves as a fallback if the key isn't set. + +Inside of a react componet we should not use globalStore, instead we use useSettingsKeyAtom (this is just a jotai useAtomValue call wrapped around the getSettingsKeyAtom call) + +```tsx +const configValue = useSettingsKeyAtom("app:defaultnewblock") ?? "default value"; +``` + +--- + +## Relevant Imports + +```ts +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { getSettingsKeyAtom, useSettingsKeyAtom, globalStore } from "@/app/store/global"; +``` + +Keep this guide handy for a quick reference when working with configuration values. diff --git a/aiprompts/layout-simplification.md b/aiprompts/layout-simplification.md new file mode 100644 index 000000000..785bfb1cc --- /dev/null +++ b/aiprompts/layout-simplification.md @@ -0,0 +1,857 @@ +# Wave Terminal Layout System - Simplification via Write Cache Pattern + +## Executive Summary + +The current layout system uses a complex bidirectional atom architecture that forces every layout change to round-trip through the backend WaveObject, even though **the backend never reads this data** - it only queues actions via `PendingBackendActions`. By switching to a "write cache" pattern where local atoms are the source of truth and backend writes are fire-and-forget, we can eliminate ~70% of the complexity while maintaining full persistence. + +## Current Architecture Problems + +### The Unnecessary Round-Trip + +Every layout change (split, close, focus, magnify) currently follows this flow: + +``` +User action + ↓ +treeReducer() mutates layoutState + ↓ +layoutState.generation++ ← Only purpose: trigger the write + ↓ +Bidirectional atom setter (checks generation) + ↓ +Write to WaveObject {rootnode, focusednodeid, magnifiednodeid} + ↓ +WaveObject update notification + ↓ +Bidirectional atom getter runs + ↓ +ALL dependent atoms recalculate (every isFocused, etc.) + ↓ +React re-renders with updated state +``` + +**The critical insight**: The backend reads ONLY `leaforder` from the WaveObject (for block number resolution in commands like `wsh block:1`). The `rootnode`, `focusednodeid`, and `magnifiednodeid` fields exist **only for persistence** (tab restore, uncaching). + +### What the Backend Actually Does + +**Backend Reads** (from [`pkg/wshrpc/wshserver/resolvers.go`](../pkg/wshrpc/wshserver/resolvers.go:196-206)): +- **`LeafOrder`** - Used to resolve block numbers in commands (e.g., `wsh block:1` → blockId lookup) + +**Backend Writes** (from [`pkg/wcore/layout.go`](../pkg/wcore/layout.go)): +- **`PendingBackendActions`** - Queued layout actions via [`QueueLayoutAction()`](../pkg/wcore/layout.go:101-118) + +**Backend NEVER touches**: +- **`RootNode`** - Never read, only written by frontend for persistence +- **`FocusedNodeId`** - Never read, only written by frontend for persistence +- **`MagnifiedNodeId`** - Never read, only written by frontend for persistence + +**The key insight**: Only `LeafOrder` needs to be synced to backend (for command resolution). The tree structure fields (`rootnode`, `focusednodeid`, `magnifiednodeid`) are pure persistence! + +### Complexity Symptoms + +1. **Generation tracking**: [`layoutState.generation++`](../frontend/layout/lib/layoutTree.ts:294) appears in 10+ places, only to trigger atom writes +2. **Bidirectional atoms**: [`withLayoutTreeStateAtomFromTab()`](../frontend/layout/lib/layoutAtom.ts:18-60) has complex read/write logic +3. **Timing coordination**: The entire Section 8 of the WaveAI focus proposal exists only because of race conditions between focus updates and atom commits +4. **False reactivity**: Changes to `focusedNodeId` trigger full tree state propagation even though they're unrelated to tree structure + +## Proposed "Write Cache" Architecture + +### Core Concept + +``` +User action + ↓ +Update LOCAL atom (immediate, synchronous) + ↓ +React re-renders (single tick, all atoms see new state) + ↓ +[async, fire-and-forget] Persist to WaveObject +``` + +### Key Principles + +1. **Local atoms are source of truth** during runtime +2. **WaveObject is persistence layer** only (read on init, write async) +3. **Backend actions still work** via `PendingBackendActions` +4. **No generation tracking needed** (no need to trigger writes) + +## Implementation Design + +### 1. New LayoutModel Structure + +```typescript +// frontend/layout/lib/layoutModel.ts + +class LayoutModel { + // BEFORE: Bidirectional atom with generation tracking + // treeStateAtom: WritableLayoutTreeStateAtom + + // AFTER: Simple local atom (source of truth) + private localTreeStateAtom: PrimitiveAtom; + + // Keep reference to WaveObject atom for persistence + private waveObjectAtom: WritableWaveObjectAtom; + + constructor(tabAtom: Atom, ...) { + this.waveObjectAtom = getLayoutStateAtomFromTab(tabAtom); + + // Initialize local atom (starts empty) + this.localTreeStateAtom = atom({ + rootNode: undefined, + focusedNodeId: undefined, + magnifiedNodeId: undefined, + leafOrder: undefined, + pendingBackendActions: undefined, + generation: 0 // Can be removed entirely or kept for debugging + }); + + // Read from WaveObject ONCE during initialization + this.initializeFromWaveObject(); + } + + private async initializeFromWaveObject() { + const waveObjState = this.getter(this.waveObjectAtom); + + // Load persisted state into local atom + const initialState: LayoutTreeState = { + rootNode: waveObjState?.rootnode, + focusedNodeId: waveObjState?.focusednodeid, + magnifiedNodeId: waveObjState?.magnifiednodeid, + leafOrder: undefined, // Computed by updateTree() + pendingBackendActions: waveObjState?.pendingbackendactions, + generation: 0 + }; + + // Set local state + this.treeState = initialState; + this.setter(this.localTreeStateAtom, initialState); + + // Process any pending backend actions + if (initialState.pendingBackendActions?.length) { + await this.processPendingBackendActions(); + } + + // Initialize tree (compute leafOrder, etc.) + this.updateTree(); + } + + // Process backend-queued actions (startup only) + private async processPendingBackendActions() { + const actions = this.treeState.pendingBackendActions; + if (!actions?.length) return; + + this.treeState.pendingBackendActions = undefined; + + for (const action of actions) { + // Convert backend action to frontend action and run through treeReducer + // This code already exists in onTreeStateAtomUpdated() + switch (action.actiontype) { + case LayoutTreeActionType.InsertNode: + this.treeReducer({ + type: LayoutTreeActionType.InsertNode, + node: newLayoutNode(undefined, undefined, undefined, { + blockId: action.blockid + }), + magnified: action.magnified, + focused: action.focused + }, false); + break; + // ... other action types + } + } + } +} +``` + +### 2. Simplified treeReducer + +```typescript +class LayoutModel { + treeReducer(action: LayoutTreeAction, setState = true): boolean { + // Run the tree operation (mutates this.treeState) + switch (action.type) { + case LayoutTreeActionType.InsertNode: + insertNode(this.treeState, action); + break; + case LayoutTreeActionType.FocusNode: + focusNode(this.treeState, action); + break; + case LayoutTreeActionType.DeleteNode: + deleteNode(this.treeState, action); + break; + // ... all other cases unchanged + } + + if (setState) { + // Update tree (compute leafOrder, validate, etc.) + this.updateTree(); + + // Update local atom IMMEDIATELY (synchronous) + this.setter(this.localTreeStateAtom, { ...this.treeState }); + + // Persist to backend asynchronously (fire and forget) + this.persistToBackend(); + } + + return true; + } + + // Fire-and-forget persistence + private async persistToBackend() { + const waveObj = this.getter(this.waveObjectAtom); + if (!waveObj) return; + + // Update WaveObject fields + waveObj.rootnode = this.treeState.rootNode; // Persistence only + waveObj.focusednodeid = this.treeState.focusedNodeId; // Persistence only + waveObj.magnifiednodeid = this.treeState.magnifiedNodeId; // Persistence only + waveObj.leaforder = this.treeState.leafOrder; // Backend reads this for command resolution! + + // Write to backend (don't await - fire and forget) + this.setter(this.waveObjectAtom, waveObj); + + // Optional: Debounce if rapid changes are a concern + } +} +``` + +### 3. Simplified NodeModel isFocused + +```typescript +class LayoutModel { + getNodeModel(node: LayoutNode): NodeModel { + return { + // BEFORE: Complex dependency on bidirectional treeStateAtom + // isFocused: atom((get) => { + // const treeState = get(this.treeStateAtom); // Triggers on any tree change + // ... + // }) + + // AFTER: Simple dependency on local atom + isFocused: atom((get) => { + const treeState = get(this.localTreeStateAtom); // Simple read + const focusType = get(focusManager.focusType); + return treeState.focusedNodeId === node.id && focusType === "node"; + }), + + // All other atoms similarly simplified... + isMagnified: atom((get) => { + const treeState = get(this.localTreeStateAtom); + return treeState.magnifiedNodeId === node.id; + }), + + // ... rest unchanged + }; + } +} +``` + +### 4. Remove Generation Tracking + +The `generation` field can be removed entirely from [`LayoutTreeState`](../frontend/layout/lib/types.ts): + +```typescript +// frontend/layout/lib/types.ts + +export interface LayoutTreeState { + rootNode?: LayoutNode; + focusedNodeId?: string; + magnifiedNodeId?: string; + leafOrder?: LayoutLeafEntry[]; + pendingBackendActions?: LayoutActionData[]; + // generation: number; ← DELETE THIS +} +``` + +And remove all `generation++` calls from [`layoutTree.ts`](../frontend/layout/lib/layoutTree.ts) (appears in 10+ places). + +### 5. Simplified layoutAtom.ts + +```typescript +// frontend/layout/lib/layoutAtom.ts + +// BEFORE: Complex bidirectional atom (60 lines) +// AFTER: Can be deleted entirely or simplified to just helper for WaveObject access + +export function getLayoutStateAtomFromTab( + tabAtom: Atom, + get: Getter +): WritableWaveObjectAtom { + const tabData = get(tabAtom); + if (!tabData) return; + const layoutStateOref = WOS.makeORef("layout", tabData.layoutstate); + return WOS.getWaveObjectAtom(layoutStateOref); +} + +// No more withLayoutTreeStateAtomFromTab() - not needed! +``` + +## Benefits + +### Immediate Benefits + +1. **10x simpler reactivity**: Local atoms update synchronously, React sees complete state in one tick +2. **No generation tracking**: Eliminate 10+ `generation++` calls and all related logic +3. **No timing issues**: Everything happens synchronously, no coordination needed +4. **Faster updates**: No round-trip through WaveObject for every change +5. **Easier debugging**: Clear separation between runtime state (local atoms) and persistence (WaveObject) + +### Impact on WaveAI Focus Proposal + +The entire Section 8 ("Layout Model Focus Integration - CRITICAL TIMING") **becomes unnecessary**: + +**BEFORE** (complex timing coordination): +```typescript +treeReducer(action: LayoutTreeAction) { + insertNode(this.treeState, action); // generation++ + + // CRITICAL: Must update focus manager BEFORE atom commits + if (action.focused) { + focusManager.requestNodeFocus(); // Synchronous! + } + + // Then atom commits + this.setter(this.treeStateAtom, ...); + // Now isFocused sees correct focusType +} +``` + +**AFTER** (trivial): +```typescript +treeReducer(action: LayoutTreeAction) { + insertNode(this.treeState, action); // Just mutates local state + + // Update local atom (synchronous) + this.setter(this.localTreeStateAtom, { ...this.treeState }); + + // Update focus manager (order doesn't matter - both updated synchronously) + if (action.focused) { + focusManager.setBlockFocus(); + } + + // Both updates happen in same tick, no race condition possible! +} +``` + +### Code Deletion + +**Can delete**: +- `generation` field and all `generation++` calls (~15 places) +- Complex bidirectional atom logic in [`layoutAtom.ts`](../frontend/layout/lib/layoutAtom.ts) (~40 lines) +- `lastTreeStateGeneration` tracking in [`LayoutModel`](../frontend/layout/lib/layoutModel.ts) +- All `generation > this.treeState.generation` checks + +**Total**: ~200-300 lines of complex coordination code deleted + +## Edge Cases & Considerations + +### 1. Rapid Changes + +**Concern**: Many layout changes in quick succession could cause many backend writes. + +**Solution**: Debounce the `persistToBackend()` call (e.g., 100ms). Users won't notice the delay in persistence. + +```typescript +private persistDebounceTimer: NodeJS.Timeout | null = null; + +private persistToBackend() { + if (this.persistDebounceTimer) { + clearTimeout(this.persistDebounceTimer); + } + + this.persistDebounceTimer = setTimeout(() => { + const waveObj = this.getter(this.waveObjectAtom); + if (!waveObj) return; + + waveObj.rootnode = this.treeState.rootNode; + waveObj.focusednodeid = this.treeState.focusedNodeId; + waveObj.magnifiednodeid = this.treeState.magnifiedNodeId; + waveObj.leaforder = this.treeState.leafOrder; + + this.setter(this.waveObjectAtom, waveObj); + this.persistDebounceTimer = null; + }, 100); +} +``` + +### 2. Tab Switching + +**Current**: Each tab has its own `treeStateAtom` in a WeakMap. + +**After**: Each tab has its own `localTreeStateAtom` in the LayoutModel instance. No change needed - already isolated per tab. + +### 3. Tab Uncaching (Electron Limit) + +**Current**: Tab gets uncached, needs to reload layout from WaveObject. + +**After**: Same - `initializeFromWaveObject()` reads persisted state. No change in behavior. + +### 4. Backend Actions (New Blocks) +### 5. LeafOrder and CLI Commands + +**Concern**: The backend reads `LeafOrder` for CLI command resolution (e.g., `wsh block:1`). What if it's not synced yet? + +**Solution**: Fire-and-forget is perfectly fine! CLI commands aren't time-sensitive: +- Commands are typed/run by users (human speed, not machine speed) +- Even if `LeafOrder` is 100ms behind, no one will notice +- By the time a user types `wsh block:1`, the async write has long since completed +- Worst case: User types command during a split operation and gets previous block - extremely rare and not breaking + + +## Immutability and Jotai Atoms + +### Question: Do we need deep copies for Jotai to detect changes? + +**Answer: NO - shallow copy is sufficient!** ✓ + +### Current System (Already Uses Shallow Updates) + +Looking at the current code in [`layoutModel.ts:587`](../frontend/layout/lib/layoutModel.ts:587): + +```typescript +setTreeStateAtom(bumpGeneration = false) { + if (bumpGeneration) { + this.treeState.generation++; + } + this.lastTreeStateGeneration = this.treeState.generation; + this.setter(this.treeStateAtom, this.treeState); // ← Sets same object! +} +``` + +**The current system doesn't create new objects either!** It relies on `generation` changing to trigger the bidirectional atom's setter. + +### Why Shallow Copy Works with Jotai + +```typescript +// In treeReducer after mutations +this.setter(this.localTreeStateAtom, { ...this.treeState }); +``` + +**This works because**: +1. **Jotai checks reference equality** on the atom value itself (the `LayoutTreeState` object) +2. **`{ ...this.treeState }` creates a NEW object** with a different reference +3. **Nested structures don't matter** - Jotai doesn't do deep equality checks + +**Example**: +```typescript +const oldState = { rootNode: someTree, focusedNodeId: "node1" }; +const newState = { ...oldState }; + +oldState === newState // FALSE - different objects! +oldState.rootNode === newState.rootNode // TRUE - same tree reference + +// But Jotai only checks the first comparison, so it detects the change! +``` + +### Tree Mutations Don't Need Immutability + +All tree operations in [`layoutTree.ts`](../frontend/layout/lib/layoutTree.ts) **mutate in place**: +- `insertNode()` - Mutates `layoutState.rootNode` + +### Derived Atoms Will Update Correctly ✓ + +**Concern**: Will derived atoms like `isFocused` and `isMagnified` update when we change to local atoms? + +**Answer: YES - they will work perfectly!** ✓ + +### How Derived Atoms Work + +The NodeModel creates derived atoms that depend on `treeStateAtom`: + +```typescript +// From layoutModel.ts:936-946 +isFocused: atom((get) => { + const treeState = get(this.treeStateAtom); // Subscribe to treeStateAtom + const isFocused = treeState.focusedNodeId === nodeid; + const waveAIFocused = get(atoms.waveAIFocusedAtom); + return isFocused && !waveAIFocused; +}), + +isMagnified: atom((get) => { + const treeState = get(this.treeStateAtom); // Subscribe to treeStateAtom + return treeState.magnifiedNodeId === nodeid; +}), +``` + +### Why They'll Still Work with Local Atoms + +**After the change**: +```typescript +isFocused: atom((get) => { + const treeState = get(this.localTreeStateAtom); // Subscribe to localTreeStateAtom + const isFocused = treeState.focusedNodeId === nodeid; + const waveAIFocused = get(atoms.waveAIFocusedAtom); + return isFocused && !waveAIFocused; +}), +``` + +**The update flow**: +1. User clicks block → `focusNode()` called +2. `treeReducer()` runs → mutates `this.treeState.focusedNodeId = newId` +3. `this.setter(this.localTreeStateAtom, { ...this.treeState })` ← **New reference!** +4. Jotai detects reference change in `localTreeStateAtom` +5. All derived atoms that call `get(this.localTreeStateAtom)` are notified +6. They re-run their getter functions +7. They see the new `focusedNodeId` value +8. React components re-render with correct values ✓ + +### Key Insight + +**We're not mutating fields inside the atom** - we're replacing the entire state object: + +```typescript +// OLD way (current): +// 1. Mutate this.treeState.focusedNodeId = newId +// 2. Bump this.treeState.generation++ +// 3. Set bidirectional atom (checks generation, writes to WaveObject, reads back, updates) +// 4. Derived atoms see new state from the round-trip + +// NEW way (proposed): +// 1. Mutate this.treeState.focusedNodeId = newId (same!) +// 2. this.setter(localTreeStateAtom, { ...this.treeState }) (new object reference!) +// 3. Derived atoms immediately see new state (no round-trip!) +``` + +**Both approaches create a new state object that triggers Jotai's reactivity!** + +The new way is actually **MORE reliable** because: +- No round-trip delay +- No generation checking +- Direct, synchronous update +- Same Jotai reactivity mechanism + +### What About Nested Fields? + +**Question**: What if derived atoms access nested fields like `treeState.rootNode.children`? + +**Answer**: Still works! Example: + +```typescript +// Hypothetical derived atom +someAtom: atom((get) => { + const treeState = get(this.localTreeStateAtom); + return treeState.rootNode.children.length; // Nested access +}) +``` + +**This works because**: +1. We create new `LayoutTreeState` object: `{ ...this.treeState }` +2. Jotai sees new reference → notifies subscribers +3. Getter re-runs, calls `get(this.localTreeStateAtom)` +4. Gets the new state object +5. Accesses `newState.rootNode` (same reference as before, but that's OK!) +6. Returns correct value + +**The derived atom doesn't care that `rootNode` is the same object** - it just cares that the STATE object changed and it needs to re-evaluate. + +### Verification + +All derived atoms in NodeModel: +- ✅ `isFocused` - depends on `treeState.focusedNodeId` +- ✅ `isMagnified` - depends on `treeState.magnifiedNodeId` +- ✅ `blockNum` - depends on separate `this.leafOrder` atom (unaffected) +- ✅ `isEphemeral` - depends on separate `this.ephemeralNode` atom (unaffected) + +All will update correctly with the new local atom approach! + +- `deleteNode()` - Mutates parent's children array +- `focusNode()` - Mutates `layoutState.focusedNodeId` + +This is fine! We're not relying on immutability for change detection. We're relying on creating a new `LayoutTreeState` wrapper object via spread operator. + +### Backend Round-Trip + +When reading from WaveObject on initialization: +```typescript +const waveObjState = this.getter(this.waveObjectAtom); +const initialState: LayoutTreeState = { + rootNode: waveObjState?.rootnode, // New reference from backend + focusedNodeId: waveObjState?.focusednodeid, + // ... +}; +``` + +This creates a **completely new object** with new references, which is even more immutable than necessary. No issues here. + +### Summary + +✅ **We're covered** - Shallow copy via spread operator is sufficient + +✅ **Same as current system** - We're not making it worse, just simpler + +✅ **Jotai only checks reference equality** on the atom value, not deep equality + +✅ **Tree mutations are fine** - They've always worked this way + + +**Current**: Backend queues actions via [`QueueLayoutAction()`](../pkg/wcore/layout.go:101), frontend processes via `pendingBackendActions`. + +**After**: Same - `initializeFromWaveObject()` processes pending actions. No change needed. + +### 5. Write Failures + +**Concern**: What if the async write to WaveObject fails? + +**Solution**: +1. The app continues working (local state is fine) +2. On next persistence attempt, full state is written again +3. On tab reload, worst case is state from last successful write +4. Can add retry logic or error notification if needed + +## Migration Path + +### Phase 1: Preparation (No Breaking Changes) + +1. Add `localTreeStateAtom` alongside existing `treeStateAtom` +2. Keep both in sync +3. Update a few `isFocused` atoms to use local atom +4. Test thoroughly + +### Phase 2: Switch Over + +1. Update `treeReducer` to write to local atom + fire-and-forget persist +2. Update all `isFocused` and other computed atoms to use local atom +3. Remove generation checks and tracking +4. Test all layout operations + +### Phase 3: Cleanup + +1. Delete bidirectional atom logic from [`layoutAtom.ts`](../frontend/layout/lib/layoutAtom.ts) +2. Remove `generation` field from `LayoutTreeState` +3. Simplify `onTreeStateAtomUpdated()` (only needed for `pendingBackendActions`) +4. Update documentation + +### Testing Checklist + +- [ ] Split horizontal/vertical +- [ ] Close blocks (focused and unfocused) +- [ ] Focus changes via click, keyboard nav, tab switching +- [ ] Magnify/unmagnify +- [ ] Resize operations +- [ ] Drag & drop +- [ ] Tab switching (verify state persistence) +- [ ] App restart (verify state restore) +- [ ] Multiple windows +- [ ] Rapid operations (verify debouncing works) + +## Impact on Other Systems + +### Focus Manager + +**Before**: Must coordinate timing with atom commits. + +**After**: Can update `focusType` atom independently. Order doesn't matter since both updates happen synchronously. + +### Block Component + +**No change**: Blocks still subscribe to `nodeModel.isFocused`, which still reacts correctly (faster now). + +### Keyboard Navigation + +**No change**: Still calls `layoutModel.focusNode()`, which updates local state immediately. + +### Terminal/Views + +**No change**: Views don't interact with layout atoms directly. + +## Performance Implications + +### Improved + +1. **Faster reactivity**: No round-trip through WaveObject (save ~1-2ms per operation) +2. **Fewer atom updates**: Only local atom updates, not bidirectional propagation +3. **Batched writes**: Debouncing reduces backend write frequency + +### No Change + +1. **Tree operations**: Same complexity (balance, walk, compute, etc.) +2. **React rendering**: Same render triggers, just faster +3. **Memory usage**: Same (local atom vs bidirectional atom is similar size) + +## Conclusion + +The "write cache" pattern can simplify the layout system by ~70% while maintaining full functionality: + +- **Remove**: Generation tracking, bidirectional atoms, timing coordination +- **Keep**: All tree logic, backend integration, persistence +- **Gain**: Simpler code, faster updates, easier debugging + +This also makes the WaveAI focus integration trivial, eliminating the need for complex timing coordination. + +## Recommendation + +Implement this simplification **before** adding WaveAI focus features. The cleaner foundation will make the focus work much easier and the codebase more maintainable long-term. +# Wave Terminal Layout System - Simplification via Write Cache Pattern + +## Risk Assessment: LOW RISK, Well-Contained Change + +### Files to Modify: **4-5 files, all in `frontend/layout/`** + +1. **`frontend/layout/lib/layoutModel.ts`** (~150 lines changed) + - Add `localTreeStateAtom` field + - Modify `treeReducer()` to update local atom + persist async + - Add `initializeFromWaveObject()` method + - Add `persistToBackend()` method + - Update `getNodeModel()` atoms to use local atom + +2. **`frontend/layout/lib/layoutTree.ts`** (~15 line deletions) + - Remove all `layoutState.generation++` calls (appears 15 times) + - No other changes needed + +3. **`frontend/layout/lib/layoutAtom.ts`** (~40 lines deleted or simplified) + - Can delete most of the bidirectional atom logic + - Keep only `getLayoutStateAtomFromTab()` helper + +4. **`frontend/layout/lib/types.ts`** (~1 line deletion) + - Remove `generation: number` from `LayoutTreeState` + +5. **`frontend/layout/tests/model.ts`** (~1 line change) + - Remove generation from test fixtures + +**Total**: ~5 files, all within `frontend/layout/` directory. **No changes outside layout system!** + +### Why This is Low Risk + +#### 1. **Fail-Fast Behavior** ✓ +If we break something, it will be **immediately obvious**: +- Split horizontal/vertical won't work → visible immediately +- Block focus won't work → obvious when clicking +- Close block won't work → obvious +- Magnify won't work → obvious + +**No subtle corruption**: This change affects reactive state flow, not data persistence. If it breaks, the UI breaks obviously. We won't get "sometimes it works, sometimes it doesn't." + +#### 2. **Well-Contained Scope** ✓ +- **All changes in one directory**: `frontend/layout/` +- **No changes to**: + - Block components (unchanged) + - Terminal/views (unchanged) + - Keyboard navigation (unchanged) + - Focus manager (unchanged) + - Backend Go code (unchanged) + +The **interface** to the layout system stays the same: +- Blocks still call `nodeModel.focusNode()` +- Blocks still subscribe to `nodeModel.isFocused` +- Keyboard nav still calls `layoutModel.focusNode()` +- Nothing outside the layout system needs to know about the change + +#### 3. **No Data Corruption Risk** ✓ +This change affects **reactive state propagation**, not data storage: +- WaveObject still stores the same data +- Backend still queues actions the same way +- Blocks still have the same IDs +- Tab structure unchanged + +**Worst case**: Layout stops working, we revert the code. No data loss, no corruption. + +#### 4. **Incremental Implementation Possible** ✓ + +Can be done in safe phases: + +**Phase 1**: Add alongside existing (no breaking changes) +```typescript +class LayoutModel { + treeStateAtom: WritableLayoutTreeStateAtom; // Keep old + localTreeStateAtom: PrimitiveAtom; // Add new + + // Keep both in sync temporarily +} +``` + +**Phase 2**: Switch consumers one at a time +```typescript +// Change this gradually +isFocused: atom((get) => { + // const treeState = get(this.treeStateAtom); // Old + const treeState = get(this.localTreeStateAtom); // New + ... +}) +``` + +**Phase 3**: Remove old code once everything uses new atoms + +**Can test thoroughly at each phase before proceeding!** + +#### 5. **Easy to Test** ✓ + +Every layout operation is user-visible and testable: +- [ ] Split horizontal → obvious if broken +- [ ] Split vertical → obvious if broken +- [ ] Close block → obvious if broken +- [ ] Focus block → obvious if broken +- [ ] Magnify/unmagnify → obvious if broken +- [ ] Drag & drop → obvious if broken +- [ ] Tab switch → obvious if broken +- [ ] App restart → obvious if broken + +No subtle edge cases to hunt down. If it works in manual testing, it works. + +### Comparison to High-Risk Changes + +**This change is NOT**: +- ❌ Touching 20+ files across the codebase +- ❌ Changing subtle timing in async operations +- ❌ Modifying data storage formats +- ❌ Affecting backend/frontend protocol +- ❌ Requiring coordinated backend changes +- ❌ Creating subtle race conditions + +**This change IS**: +- ✅ Contained to 5 files in one directory +- ✅ Synchronous state updates (simpler than current!) +- ✅ Same data format, just different flow +- ✅ Frontend-only +- ✅ Backend unchanged +- ✅ Eliminating race conditions (not creating them) + +### What Could Go Wrong? (And How We'd Know) + +| Potential Issue | How We'd Detect | Recovery | +|-----------------|-----------------|----------| +| Local atom doesn't update | Layout frozen, nothing responds | Immediately obvious, revert | +| Persistence fails silently | State doesn't survive restart | Caught in testing, add logging | +| isFocused calculation wrong | Wrong focus ring | Immediately obvious, fix calculation | +| Missing generation++ somewhere | Old code path tries to use generation | Compile error or immediate runtime error | +| Tab switching breaks | Tabs don't load correctly | Immediately obvious | + +**All failure modes are immediate and obvious!** + +### Difficulty Assessment + +**Conceptual Difficulty**: LOW +- Replace bidirectional atom with simple atom +- Add async persist function +- Remove generation tracking +- Very straightforward refactor + +**Code Difficulty**: LOW-MEDIUM +- Changes are localized and mechanical +- Most changes are deletions (always good!) +- New code is simpler than old code +- No complex algorithms to implement + +**Testing Difficulty**: LOW +- All functionality is user-visible +- No need for complex test scenarios +- Manual testing catches everything +- Can test incrementally + +### Recommendation + +This is a **low-risk, high-reward change**: +- **Risk**: LOW (contained, fail-fast, no corruption) +- **Difficulty**: LOW-MEDIUM (straightforward refactor) +- **Reward**: HIGH (70% less complexity, easier future work) + +**Suggested approach**: +1. Implement in a feature branch +2. Add local atom alongside existing system +3. Test thoroughly with both systems running +4. Switch over gradually +5. Remove old code +6. Merge when confident + +Total implementation time: **1-2 days for experienced developer**, including thorough testing. + +--- diff --git a/aiprompts/layout.md b/aiprompts/layout.md new file mode 100644 index 000000000..0c1a8fe7e --- /dev/null +++ b/aiprompts/layout.md @@ -0,0 +1,413 @@ +# Wave Terminal Layout System Architecture + +The Wave Terminal layout system is a sophisticated tile-based layout engine built with React, TypeScript, and Jotai state management. It provides a flexible, drag-and-drop interface for arranging terminal blocks and other content in complex layouts. + +## Overview + +The layout system manages a tree of `LayoutNode` objects that represent the hierarchical structure of content. Each node can either be: +- **Leaf node**: Contains actual content (block data) +- **Container node**: Contains child nodes with a specific flex direction + +The system uses CSS Flexbox for positioning but maintains its own tree structure for state management, drag-and-drop operations, and complex layout manipulations. + +## Core Architecture + +### File Structure + +``` +frontend/layout/lib/ +├── TileLayout.tsx # Main React component +├── layoutAtom.ts # Jotai state management +├── layoutModel.ts # Core model class +├── layoutModelHooks.ts # React hooks for integration +├── layoutNode.ts # Node manipulation functions +├── layoutTree.ts # Tree operation functions +├── nodeRefMap.ts # DOM reference tracking +├── types.ts # Type definitions +├── utils.ts # Utility functions +└── tilelayout.scss # Styling +``` + +## Key Data Structures + +### LayoutNode + +The fundamental building block of the layout system: + +```typescript +interface LayoutNode { + id: string; // Unique identifier + data?: TabLayoutData; // Content data (only for leaf nodes) + children?: LayoutNode[]; // Child nodes (only for containers) + flexDirection: FlexDirection; // "row" or "column" + size: number; // Flex size (0-100) +} +``` + +**Key Rules:** +- Either `data` OR `children` must be defined, never both +- Leaf nodes have `data`, container nodes have `children` +- All nodes have a `flexDirection` that determines layout axis +- `size` represents the relative flex size within the parent + +### LayoutTreeState + +The complete state of the layout: + +```typescript +interface LayoutTreeState { + rootNode: LayoutNode; // Root of the tree + focusedNodeId?: string; // Currently focused node + magnifiedNodeId?: string; // Currently magnified node + leafOrder?: LeafOrderEntry[]; // Computed leaf ordering + pendingBackendActions: LayoutActionData[]; // Actions from backend + generation: number; // State version number +} +``` + +**Generation System:** +- Incremented on every state change +- Used for optimistic updates and conflict resolution +- Prevents stale state overwrites + +### NodeModel + +Runtime model for individual nodes, providing React-friendly state: + +```typescript +interface NodeModel { + additionalProps: Atom; + innerRect: Atom; + blockNum: Atom; + nodeId: string; + blockId: string; + isFocused: Atom; + isMagnified: Atom; + isEphemeral: Atom; + toggleMagnify: () => void; + focusNode: () => void; + onClose: () => void; + dragHandleRef?: React.RefObject; + // ... additional state and methods +} +``` + +## Core Classes + +### LayoutModel + +The central orchestrator that manages the entire layout system: + +**Key Responsibilities:** +- Maintains tree state through Jotai atoms +- Processes layout actions (move, resize, insert, delete) +- Computes layout positions and transforms +- Manages drag-and-drop operations +- Handles resize operations +- Provides node models for React components + +**State Management:** +```typescript +class LayoutModel { + treeStateAtom: WritableLayoutTreeStateAtom; // Persistent state + leafs: PrimitiveAtom; // Computed leaf nodes + additionalProps: PrimitiveAtom>; + pendingTreeAction: AtomWithThrottle; + activeDrag: PrimitiveAtom; + // ... many more atoms for different aspects +} +``` + +**Action Processing:** +The model uses a reducer pattern to process actions: +```typescript +treeReducer(action: LayoutTreeAction) { + switch (action.type) { + case LayoutTreeActionType.Move: + moveNode(this.treeState, action); + break; + case LayoutTreeActionType.InsertNode: + insertNode(this.treeState, action); + break; + // ... handle all action types + } + this.updateTree(); // Recompute derived state +} +``` + +## Layout Actions + +The system uses a comprehensive action system for all modifications: + +### Action Types + +```typescript +enum LayoutTreeActionType { + ComputeMove = "computemove", // Preview move operation + Move = "move", // Execute move + Swap = "swap", // Swap two nodes + ResizeNode = "resize", // Resize node(s) + InsertNode = "insert", // Insert new node + InsertNodeAtIndex = "insertatindex", // Insert at specific index + DeleteNode = "delete", // Remove node + FocusNode = "focus", // Change focus + MagnifyNodeToggle = "magnify", // Toggle magnification + SplitHorizontal = "splithorizontal", // Split horizontally + SplitVertical = "splitvertical", // Split vertically + // ... more actions +} +``` + +### Action Flow + +1. **User Interaction** → Action triggered +2. **Action Validation** → Check if operation is valid +3. **Tree Modification** → Update `LayoutTreeState` +4. **State Propagation** → Update Jotai atoms +5. **Layout Computation** → Recalculate positions +6. **React Re-render** → Update UI + +### Example: Move Operation + +```typescript +// 1. Compute operation during drag +const computeAction: LayoutTreeComputeMoveNodeAction = { + type: LayoutTreeActionType.ComputeMove, + nodeId: targetNodeId, + nodeToMoveId: draggedNodeId, + direction: DropDirection.Right +}; + +// 2. Execute on drop +const moveAction: LayoutTreeMoveNodeAction = { + type: LayoutTreeActionType.Move, + parentId: newParentId, + index: insertIndex, + node: nodeToMove +}; +``` + +## Drag and Drop System + +The layout system implements a sophisticated drag-and-drop interface using `react-dnd`. + +### Drop Direction Logic + +When dragging over a node, the system determines drop direction based on cursor position: + +```typescript +enum DropDirection { + Top = 0, Right = 1, Bottom = 2, Left = 3, + OuterTop = 4, OuterRight = 5, OuterBottom = 6, OuterLeft = 7, + Center = 8 +} +``` + +**Drop Zones:** +- **Inner zones** (Top/Right/Bottom/Left): Insert within the target node +- **Outer zones**: Insert in the target's parent +- **Center**: Swap nodes + +### Drag Preview + +The system generates drag previews by: +1. Rendering content to an off-screen element +2. Converting to PNG using `html-to-image` +3. Using the image as the drag preview + +## Resize System + +### Resize Handles + +Resize handles are dynamically positioned between adjacent nodes: + +```typescript +interface ResizeHandleProps { + id: string; + parentNodeId: string; + parentIndex: number; + centerPx: number; // Handle position + transform: CSSProperties; // CSS positioning + flexDirection: FlexDirection; // Handle orientation +} +``` + +### Resize Operation + +1. **Handle Drag Start** → Store resize context +2. **Drag Move** → Compute new sizes based on cursor position +3. **Throttled Updates** → Update node sizes (10ms throttle) +4. **Drag End** → Commit final sizes + +## Layout Computation + +The system computes absolute positions from the tree structure: + +### Process + +1. **Tree Walk** → Traverse from root to leaves +2. **Flexbox Simulation** → Calculate container and child sizes +3. **Position Calculation** → Compute absolute positions +4. **Transform Generation** → Create CSS transforms +5. **Handle Positioning** → Place resize handles between nodes + +### Key Functions + +- [`updateTreeHelper()`](frontend/layout/lib/layoutModel.ts:638) - Main layout computation +- [`computeNodeFromProps()`](frontend/layout/lib/layoutModel.ts:718) - Individual node positioning +- [`setTransform()`](frontend/layout/lib/utils.ts:61) - CSS transform generation + +## Node Management + +### Node Operations + +The [`layoutNode.ts`](frontend/layout/lib/layoutNode.ts) file provides core node manipulation: + +```typescript +// Create new node +newLayoutNode(flexDirection?, size?, children?, data?) + +// Tree traversal +findNode(node, id) +findParent(node, id) +walkNodes(node, beforeCallback?, afterCallback?) + +// Modifications +addChildAt(node, index, ...children) +removeChild(parent, childToRemove) +balanceNode(node) // Optimize tree structure +``` + +### Tree Balancing + +The system automatically optimizes the tree structure: +- Removes unnecessary intermediate nodes +- Flattens single-child containers +- Ensures valid flex directions + +## State Synchronization + +### Frontend ↔ Backend Sync + +The layout state synchronizes with the backend through: + +1. **`layoutAtom.ts`** - Jotai atom that wraps backend state +2. **Generation tracking** - Prevents state conflicts +3. **Pending actions** - Backend-initiated changes +4. **Leaf order** - Frontend-computed ordering sent to backend + +### Atom Structure + +```typescript +const layoutTreeStateAtom = atom( + (get) => { + // Read from backend + const layoutState = get(backendLayoutStateAtom); + return transformToTreeState(layoutState); + }, + (get, set, treeState) => { + // Write to backend + if (generationNewer(treeState)) { + set(backendLayoutStateAtom, transformFromTreeState(treeState)); + } + } +); +``` + +## Special Features + +### Magnification + +Nodes can be magnified to take up the full layout space: +- Magnified nodes appear above others (higher z-index) +- Only one node can be magnified at a time +- Animation smoothly transitions between normal and magnified states + +### Ephemeral Nodes + +Temporary nodes that aren't part of the persistent tree: +- Used for preview/temporary content +- Automatically cleaned up +- Appear above the normal layout + +### Focus Management + +- One node can be focused at a time +- Focus affects keyboard navigation +- Integrates with the terminal's block focus system + +## Integration Points + +### React Integration + +**Hooks:** +- [`useTileLayout()`](frontend/layout/lib/layoutModelHooks.ts:51) - Main hook for layout setup +- [`useNodeModel()`](frontend/layout/lib/layoutModelHooks.ts:65) - Get node model for component +- [`useDebouncedNodeInnerRect()`](frontend/layout/lib/layoutModelHooks.ts:69) - Animated positioning + +### Content Rendering + +The layout system is content-agnostic through render callbacks: + +```typescript +interface TileLayoutContents { + renderContent: (nodeModel: NodeModel) => React.ReactNode; + renderPreview?: (nodeModel: NodeModel) => React.ReactElement; + onNodeDelete?: (data: TabLayoutData) => Promise; +} +``` + +### Performance Optimizations + +1. **Memoization** - Extensive use of `React.memo()` and `useMemo()` +2. **Throttling** - Resize and drag operations throttled to 10-50ms +3. **Transform-based positioning** - Uses CSS transforms for performance +4. **Split atoms** - Jotai `splitAtom()` for efficient array updates +5. **Selective re-rendering** - Only affected components re-render + +## Common Patterns + +### Adding New Actions + +1. Define action type in [`types.ts`](frontend/layout/lib/types.ts) +2. Implement handler in [`layoutTree.ts`](frontend/layout/lib/layoutTree.ts) +3. Add case to [`LayoutModel.treeReducer()`](frontend/layout/lib/layoutModel.ts:330) +4. Update generation and call `updateTree()` + +### Extending Node Properties + +1. Add to `LayoutNodeAdditionalProps` in [`types.ts`](frontend/layout/lib/types.ts) +2. Compute in [`updateTreeHelper()`](frontend/layout/lib/layoutModel.ts:638) +3. Access via `nodeModel.additionalProps` + +### Custom Layout Behaviors + +Override or extend layout computation by: +1. Modifying [`computeNodeFromProps()`](frontend/layout/lib/layoutModel.ts:718) +2. Adding custom CSS transforms +3. Implementing special handling in action reducers + +## Error Handling + +The system includes extensive validation: +- Node structure validation +- Action parameter checking +- Tree consistency checks +- Graceful degradation on errors + +## Testing + +The layout system includes comprehensive tests: +- [`layoutNode.test.ts`](frontend/layout/tests/layoutNode.test.ts) - Node operations +- [`layoutTree.test.ts`](frontend/layout/tests/layoutTree.test.ts) - Tree operations +- [`utils.test.ts`](frontend/layout/tests/utils.test.ts) - Utility functions + +## Debugging + +For debugging layout issues: +1. Check `treeState.generation` for state changes +2. Inspect `additionalProps` for computed layout data +3. Use browser dev tools to examine CSS transforms +4. Enable console logging in action reducers + +The layout system is complex but well-structured, providing a powerful foundation for Wave Terminal's dynamic layout capabilities. \ No newline at end of file diff --git a/aiprompts/newview.md b/aiprompts/newview.md new file mode 100644 index 000000000..ddb2da57f --- /dev/null +++ b/aiprompts/newview.md @@ -0,0 +1,526 @@ +# Creating a New View in Wave Terminal + +This guide explains how to implement a new view type in Wave Terminal. Views are the core content components displayed within blocks in the terminal interface. + +## Architecture Overview + +Wave Terminal uses a **Model-View architecture** where: +- **ViewModel** - Contains all state, logic, and UI configuration as Jotai atoms +- **ViewComponent** - Pure React component that renders the UI using the model +- **BlockFrame** - Wraps views with a header, connection management, and standard controls + +The separation between model and component ensures: +- Models can update state without React hooks +- Components remain pure and testable +- State is centralized in Jotai atoms for easy access + +## ViewModel Interface + +Every view must implement the `ViewModel` interface defined in [`frontend/types/custom.d.ts`](../frontend/types/custom.d.ts:285-341): + +```typescript +interface ViewModel { + // Required: The type identifier for this view (e.g., "term", "web", "preview") + viewType: string; + + // Required: The React component that renders this view + viewComponent: ViewComponent; + + // Optional: Icon shown in block header (FontAwesome icon name or IconButtonDecl) + viewIcon?: jotai.Atom; + + // Optional: Display name shown in block header (e.g., "Terminal", "Web", "Preview") + viewName?: jotai.Atom; + + // Optional: Additional header elements (text, buttons, inputs) shown after the name + viewText?: jotai.Atom; + + // Optional: Icon button shown before the view name in header + preIconButton?: jotai.Atom; + + // Optional: Icon buttons shown at the end of the header (before settings/close) + endIconButtons?: jotai.Atom; + + // Optional: Custom background styling for the block + blockBg?: jotai.Atom; + + // Optional: If true, completely hides the block header + noHeader?: jotai.Atom; + + // Optional: If true, shows connection picker in header for remote connections + manageConnection?: jotai.Atom; + + // Optional: If true, filters out 'nowsh' connections from connection picker + filterOutNowsh?: jotai.Atom; + + // Optional: If true, shows S3 connections in connection picker + showS3?: jotai.Atom; + + // Optional: If true, removes default padding from content area + noPadding?: jotai.Atom; + + // Optional: Atoms for managing in-block search functionality + searchAtoms?: SearchAtoms; + + // Optional: Returns whether this is a basic terminal (for multi-input feature) + isBasicTerm?: (getFn: jotai.Getter) => boolean; + + // Optional: Returns context menu items for the settings dropdown + getSettingsMenuItems?: () => ContextMenuItem[]; + + // Optional: Focuses the view when called, returns true if successful + giveFocus?: () => boolean; + + // Optional: Handles keyboard events, returns true if handled + keyDownHandler?: (e: WaveKeyboardEvent) => boolean; + + // Optional: Cleanup when block is closed + dispose?: () => void; +} +``` + +### Key Concepts + +**Atoms**: All UI-related properties must be Jotai atoms. This enables: +- Reactive updates when state changes +- Access from anywhere via `globalStore.get()`/`globalStore.set()` +- Derived atoms that compute values from other atoms + +**ViewComponent**: The React component receives these props: +```typescript +type ViewComponentProps = { + blockId: string; // Unique ID for this block + blockRef: React.RefObject; // Ref to block container + contentRef: React.RefObject; // Ref to content area + model: T; // Your ViewModel instance +}; +``` + +## Step-by-Step Guide + +### 1. Create the View Model Class + +Create a new file for your view model (e.g., `frontend/app/view/myview/myview-model.ts`): + +```typescript +import { BlockNodeModel } from "@/app/block/blocktypes"; +import { globalStore } from "@/app/store/jotaiStore"; +import { WOS, useBlockAtom } from "@/store/global"; +import * as jotai from "jotai"; +import { MyView } from "./myview"; + +export class MyViewModel implements ViewModel { + viewType: string; + blockId: string; + nodeModel: BlockNodeModel; + blockAtom: jotai.Atom; + + // Define your atoms (simple field initializers) + viewIcon = jotai.atom("circle"); + viewName = jotai.atom("My View"); + noPadding = jotai.atom(true); + + // Derived atom (created in constructor) + viewText!: jotai.Atom; + + constructor(blockId: string, nodeModel: BlockNodeModel) { + this.viewType = "myview"; + this.blockId = blockId; + this.nodeModel = nodeModel; + this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); + + // Create derived atoms that depend on block data or other atoms + this.viewText = jotai.atom((get) => { + const blockData = get(this.blockAtom); + const rtn: HeaderElem[] = []; + + // Add header buttons/text based on state + rtn.push({ + elemtype: "iconbutton", + icon: "refresh", + title: "Refresh", + click: () => this.refresh(), + }); + + return rtn; + }); + } + + get viewComponent(): ViewComponent { + return MyView; + } + + refresh() { + // Update state using globalStore + // Never use React hooks in model methods + console.log("refreshing..."); + } + + giveFocus(): boolean { + // Focus your view component + return true; + } + + dispose() { + // Cleanup resources (unsubscribe from events, etc.) + } +} +``` + +### 2. Create the View Component + +Create your React component (e.g., `frontend/app/view/myview/myview.tsx`): + +```typescript +import { ViewComponentProps } from "@/app/block/blocktypes"; +import { MyViewModel } from "./myview-model"; +import { useAtomValue } from "jotai"; +import "./myview.scss"; + +export const MyView: React.FC> = ({ + blockId, + model, + contentRef +}) => { + // Use atoms from the model (these are React hooks - call at top level!) + const blockData = useAtomValue(model.blockAtom); + + return ( +
+
Block ID: {blockId}
+
View: {model.viewType}
+ {/* Your view content here */} +
+ ); +}; +``` + +### 3. Register the View + +Add your view to the `BlockRegistry` in [`frontend/app/block/block.tsx`](../frontend/app/block/block.tsx:42-55): + +```typescript +const BlockRegistry: Map = new Map(); +BlockRegistry.set("term", TermViewModel); +BlockRegistry.set("preview", PreviewModel); +BlockRegistry.set("web", WebViewModel); +// ... existing registrations ... +BlockRegistry.set("myview", MyViewModel); // Add your view here +``` + +The registry key (e.g., `"myview"`) becomes the view type used in block metadata. + +### 4. Create Blocks with Your View + +Users can create blocks with your view type: +- Via CLI: `wsh view myview` +- Via RPC: Use the block's `meta.view` field set to `"myview"` + +## Real-World Examples + +### Example 1: Terminal View ([`term-model.ts`](../frontend/app/view/term/term-model.ts)) + +The terminal view demonstrates: +- **Connection management** via `manageConnection` atom +- **Dynamic header buttons** showing shell status (play/restart) +- **Mode switching** between terminal and vdom views +- **Custom keyboard handling** for terminal-specific shortcuts +- **Focus management** to focus the xterm.js instance +- **Shell integration status** showing AI capability indicators + +Key features: +```typescript +this.manageConnection = jotai.atom((get) => { + const termMode = get(this.termMode); + if (termMode == "vdom") return false; + return true; // Show connection picker for regular terminal mode +}); + +this.endIconButtons = jotai.atom((get) => { + const shellProcStatus = get(this.shellProcStatus); + const buttons: IconButtonDecl[] = []; + + if (shellProcStatus == "running") { + buttons.push({ + elemtype: "iconbutton", + icon: "refresh", + title: "Restart Shell", + click: this.forceRestartController.bind(this), + }); + } + return buttons; +}); +``` + +### Example 2: Web View ([`webview.tsx`](../frontend/app/view/webview/webview.tsx)) + +The web view shows: +- **Complex header controls** (back/forward/home/URL input) +- **State management** for loading, URL, and navigation +- **Event handling** for webview navigation events +- **Custom styling** with `noPadding` for full-bleed content +- **Media controls** showing play/pause/mute when media is active + +Key features: +```typescript +this.viewText = jotai.atom((get) => { + const url = get(this.url); + const rtn: HeaderElem[] = []; + + // Navigation buttons + rtn.push({ + elemtype: "iconbutton", + icon: "chevron-left", + click: this.handleBack.bind(this), + disabled: this.shouldDisableBackButton(), + }); + + // URL input with nested controls + rtn.push({ + elemtype: "div", + className: "block-frame-div-url", + children: [ + { + elemtype: "input", + value: url, + onChange: this.handleUrlChange.bind(this), + onKeyDown: this.handleKeyDown.bind(this), + }, + { + elemtype: "iconbutton", + icon: "rotate-right", + click: this.handleRefresh.bind(this), + } + ], + }); + + return rtn; +}); +``` + +## Header Elements (`HeaderElem`) + +The `viewText` atom can return an array of these element types: + +```typescript +// Icon button +{ + elemtype: "iconbutton", + icon: "refresh", + title: "Tooltip text", + click: () => { /* handler */ }, + disabled?: boolean, + iconColor?: string, + iconSpin?: boolean, + noAction?: boolean, // Shows icon but no click action +} + +// Text element +{ + elemtype: "text", + text: "Display text", + className?: string, + noGrow?: boolean, + ref?: React.RefObject, + onClick?: (e: React.MouseEvent) => void, +} + +// Text button +{ + elemtype: "textbutton", + text: "Button text", + className?: string, + title: "Tooltip", + onClick: (e: React.MouseEvent) => void, +} + +// Input field +{ + elemtype: "input", + value: string, + className?: string, + onChange: (e: React.ChangeEvent) => void, + onKeyDown?: (e: React.KeyboardEvent) => void, + onFocus?: (e: React.FocusEvent) => void, + onBlur?: (e: React.FocusEvent) => void, + ref?: React.RefObject, +} + +// Container with children +{ + elemtype: "div", + className?: string, + children: HeaderElem[], + onMouseOver?: (e: React.MouseEvent) => void, + onMouseOut?: (e: React.MouseEvent) => void, +} + +// Menu button (dropdown) +{ + elemtype: "menubutton", + // ... MenuButtonProps ... +} +``` + +## Best Practices + +### Jotai Model Pattern + +Follow these rules for Jotai atoms in models: + +1. **Simple atoms as field initializers**: + ```typescript + viewIcon = jotai.atom("circle"); + noPadding = jotai.atom(true); + ``` + +2. **Derived atoms in constructor** (need dependency on other atoms): + ```typescript + constructor(blockId: string, nodeModel: BlockNodeModel) { + this.viewText = jotai.atom((get) => { + const blockData = get(this.blockAtom); + return [/* computed based on blockData */]; + }); + } + ``` + +3. **Models never use React hooks** - Use `globalStore.get()`/`set()`: + ```typescript + refresh() { + const currentData = globalStore.get(this.blockAtom); + globalStore.set(this.dataAtom, newData); + } + ``` + +4. **Components use hooks for atoms**: + ```typescript + const data = useAtomValue(model.dataAtom); + const [value, setValue] = useAtom(model.valueAtom); + ``` + +### State Management + +- All view state should live in atoms on the model +- Use `useBlockAtom()` helper for block-scoped atoms that persist +- Use `globalStore` for imperative access outside React components +- Subscribe to Wave events using `waveEventSubscribe()` + +### Styling + +- Create a `.scss` file for your view styles +- Use Tailwind utilities where possible (v4) +- Add `noPadding: atom(true)` for full-bleed content +- Use `blockBg` atom to customize block background + +### Focus Management + +Implement `giveFocus()` to focus your view when: +- Block gains focus via keyboard navigation +- User clicks the block +- Return `true` if successfully focused, `false` otherwise + +### Keyboard Handling + +Implement `keyDownHandler(e: WaveKeyboardEvent)` for: +- View-specific keyboard shortcuts +- Return `true` if event was handled (prevents propagation) +- Use `keyutil.checkKeyPressed(waveEvent, "Cmd:K")` for shortcut checks + +### Cleanup + +Implement `dispose()` to: +- Unsubscribe from Wave events +- Unregister routes/handlers +- Clear timers/intervals +- Release resources + +### Connection Management + +For views that need remote connections: +```typescript +this.manageConnection = jotai.atom(true); // Show connection picker +this.filterOutNowsh = jotai.atom(true); // Hide nowsh connections +this.showS3 = jotai.atom(true); // Show S3 connections +``` + +Access connection status: +```typescript +const connStatus = jotai.atom((get) => { + const blockData = get(this.blockAtom); + const connName = blockData?.meta?.connection; + return get(getConnStatusAtom(connName)); +}); +``` + +## Common Patterns + +### Reading Block Metadata + +```typescript +import { getBlockMetaKeyAtom } from "@/store/global"; + +// In constructor: +this.someFlag = getBlockMetaKeyAtom(blockId, "myview:flag"); + +// In component: +const flag = useAtomValue(model.someFlag); +``` + +### Configuration Overrides + +Wave has a hierarchical config system (global → connection → block): + +```typescript +import { getOverrideConfigAtom } from "@/store/global"; + +this.settingAtom = jotai.atom((get) => { + // Checks block meta, then connection config, then global settings + return get(getOverrideConfigAtom(this.blockId, "myview:setting")) ?? defaultValue; +}); +``` + +### Updating Block Metadata + +```typescript +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { WOS } from "@/store/global"; + +await RpcApi.SetMetaCommand(TabRpcClient, { + oref: WOS.makeORef("block", this.blockId), + meta: { "myview:key": value }, +}); +``` + +### Search Integration + +To add in-block search: + +```typescript +import { useSearch } from "@/app/element/search"; + +// In model: +this.searchAtoms = useSearch(); // Call in component, not model! + +// In component: +const searchAtoms = useSearch(); +// Pass to model or use directly +``` + +## Testing Your View + +1. Build the frontend: `task build:dev` or `task electron:dev` +2. Create a block with your view type +3. Test all interactive elements (buttons, inputs, etc.) +4. Test keyboard shortcuts +5. Test focus behavior +6. Test cleanup (close block and check console for errors) +7. Test with different block configurations via metadata + +## Additional Resources + +- [`frontend/app/block/blockframe.tsx`](../frontend/app/block/blockframe.tsx) - Block header rendering +- [`frontend/app/view/term/term-model.ts`](../frontend/app/view/term/term-model.ts) - Complex view example +- [`frontend/app/view/webview/webview.tsx`](../frontend/app/view/webview/webview.tsx) - Navigation UI example +- [`frontend/types/custom.d.ts`](../frontend/types/custom.d.ts) - Type definitions +- Project coding rules in [`.roo/rules/`](../.roo/rules/) \ No newline at end of file diff --git a/aiprompts/tsunami-builder.md b/aiprompts/tsunami-builder.md new file mode 100644 index 000000000..eb8428956 --- /dev/null +++ b/aiprompts/tsunami-builder.md @@ -0,0 +1,261 @@ +# Tsunami AI Builder - V1 Architecture + +## Overview + +A split-screen builder for creating Tsunami applications: chat interface on left, tabbed preview/code/files on right. Users describe what they want, AI edits the code iteratively. + +## UI Layout + +### Left Panel + +- **đŸ’Ŧ Chat** - Conversation with AI + +### Right Panel + +**Top Section - Tabs:** +- **đŸ‘ī¸ Preview** (default) - Live preview of running Tsunami app, updates automatically after successful compilation +- **📝 Code** - Monaco editor for manual edits to app.go +- **📁 Files** - Static assets browser (images, etc) + +**Bottom Section - Build Panel (closable):** +- Shows compilation status and output (like VSCode's terminal panel) +- Displays success messages or errors with line numbers +- Auto-runs after AI edits +- For manual Code tab edits: auto-reruns or user clicks build button +- Can be manually closed/reopened by user + +### Top Bar + +- Current AppTitle (extracted from app.go) +- **Publish** button - Moves draft → published version +- **Revert** button - Copies published → draft (discards draft changes) + +## Version Management + +**Draft mode**: Auto-saved on every edit, persists when builder closes +**Published version**: What runs in main Wave Terminal, only updates on explicit "Publish" + +Flow: + +1. Edit in builder (always editing draft) +2. Click "Publish" when ready (copies draft → published) +3. Continue editing draft OR click "Revert" to abandon changes + +## Context Structure + +Every AI request includes: + +``` +[System Instructions] + - General system prompt + - Full system.md (Tsunami framework guide) + +[Conversation History] + - Recent messages (with prompt caching) + +[Current Context] (injected fresh each turn, removed from previous turns) + - Current app.go content + - Compilation results (success or errors with line numbers) + - Static files listing (e.g., "/static/logo.png") +``` + +**Context cleanup**: Old "current context" blocks are removed from previous messages and replaced with "[OLD CONTEXT REMOVED]" to save tokens. Only the latest app.go + compile results stay in context. + +## AI Tools + +### edit_appgo (str_replace) + +**Primary editing tool** + +- `old_str` - Unique string to find in app.go +- `new_str` - Replacement string +- `description` - What this change does + +**Backend behavior**: + +1. Apply string replacement to app.go +2. Immediately run `go build` +3. Return tool result: + - ✓ Success: "Edit applied, compilation successful" + - ✗ Failure: "Edit applied, compilation failed: [error details]" + +AI can make multiple edits in one response, getting compile feedback after each. + +### create_appgo + +**Bootstrap new apps** + +- `content` - Full app.go file content +- Only used for initial app creation or total rewrites + +Same compilation behavior as str_replace. + +### web_search + +**Look up APIs, docs, examples** + +- Implemented via provider backend (OpenAI/Anthropic) +- AI can research before making edits + +### read_file + +**Read user-provided documentation** + +- `path` - Path to file (e.g., "/docs/api-spec.md") +- User can upload docs/examples for AI to reference + +## User Actions (Not AI Tools) + +### Manage Static Assets + +- Upload via drag & drop into Files tab or file picker +- Delete files from Files tab +- Rename files from Files tab +- Appear in `/static/` directory +- Auto-injected into AI context as available files + +### Share Screenshot + +- User clicks "📷 Share preview with AI" button +- Captures current preview state +- Attaches to user's next message +- Useful for debugging layout/visual issues + +### Manual Code Editing + +- User can switch to Code tab +- Edit app.go directly in Monaco editor +- Changes auto-compile +- AI sees manual edits in next chat turn + +## Compilation Pipeline + +After every code change (AI or user): + +``` +1. Write app.go to disk +2. Run: go build app.go +3. Show build output in build panel +4. If success: + - Start/restart app process + - Update preview iframe + - Show success message in build panel +5. If failure: + - Parse error output (line numbers, messages) + - Show error in build panel (bottom of right side) + - Inject into AI context for next turn +``` + +**Auto-retry**: AI can fix its own compilation errors within the same response (up to 3 attempts). + +## Error Handling + +### Compilation Errors + +Shown in build panel at bottom of right side. + +Format for AI: + +``` +COMPILATION FAILED + +Error at line 45: + 43 | func(props TodoProps) any { + 44 | return vdom.H("div", nil +> 45 | vdom.H("span", nil, "test") + | ^ missing closing parenthesis + 46 | ) + +Message: expected ')', found 'vdom' +``` + +### Runtime Errors + +- Shown in preview tab (not errors panel) +- User can screenshot and report to AI +- Not auto-injected (v1 simplification) + +### Linting (Future) + +- Could add custom Tsunami-specific linting +- Would inject warnings alongside compile results +- Not required for v1 + +## Secrets/Configuration + +Apps can declare secrets using Tsunami's ConfigAtom: + +```go +var apiKeyAtom = app.ConfigAtom("api_key", "", &app.AtomMeta{ + Desc: "OpenAI API Key", + Secret: true, +}) +``` + +Builder detects these and shows input fields in UI for user to fill in. + +## Conversation Limits + +**V1 approach**: No summarization, no smart handling. + +When context limit hit: Show message "You've hit the conversation limit. Click 'Start Fresh' to continue editing this app in a new chat." + +Starting fresh uses current app.go as the beginning state. + +## Token Optimization + +- System.md + early messages benefit from prompt caching +- Only pay per-turn for: current app.go + new messages +- Old context blocks removed to prevent bloat +- Estimated: 10-20k tokens per turn (very manageable) + +## Example Flow + +``` +User: "Create a counter app" +AI: [calls create_appgo with full counter app] +Backend: ✓ Compiled successfully +Preview: Shows counter app + +User: "Add a reset button" +AI: [calls str_replace to add reset button] +Backend: ✓ Compiled successfully +Preview: Updates with reset button + +User: "Make buttons bigger" +AI: [calls str_replace to update button classes] +Backend: ✓ Compiled successfully +Preview: Updates with larger buttons + +User: [switches to Code tab, tweaks color manually] +Backend: ✓ Compiled successfully +Preview: Updates + +User: "Add a chart showing count over time" +AI: [calls web_search for "go charting library"] +AI: [calls str_replace to add chart] +Backend: ✗ Compilation failed - missing import +AI: [calls str_replace to add import] +Backend: ✓ Compiled successfully +Preview: Shows chart +``` + +## Out of Scope (V1) + +- Version history / snapshots +- Multiple files / project structure +- Collaboration / sharing +- Advanced linting +- Runtime error auto-injection +- Conversation summarization +- Component-specific editing tools + +These can be added in v2+ based on user feedback. + +## Success Criteria + +- User can create functional Tsunami app through chat in <5 minutes +- AI successfully fixes its own compilation errors 80%+ of the time +- Iteration cycle (message → edit → preview) takes <10 seconds +- Users can publish working apps to Wave Terminal +- Draft state persists across sessions diff --git a/aiprompts/view-prompt.md b/aiprompts/view-prompt.md new file mode 100644 index 000000000..e69de29bb diff --git a/aiprompts/wave-osc-16162.md b/aiprompts/wave-osc-16162.md new file mode 100644 index 000000000..fe9c8c835 --- /dev/null +++ b/aiprompts/wave-osc-16162.md @@ -0,0 +1,215 @@ +# Wave Terminal OSC 16162 Escape Sequences + +Wave Terminal uses a custom OSC (Operating System Command) escape sequence numbered **16162** for shell integration. This allows the shell to communicate its state and events to the terminal. + +## Format + +All commands use this escape sequence format: + +``` +ESC ] 16162 ; command [;] BEL +``` + +Where: +- `ESC` = `\033` (escape character) +- `BEL` = `\007` (bell character) +- `command` = Single letter (A, C, M, D, I, or R) +- `` = Optional JSON payload (depends on command) + +## Commands + +### A - Prompt Start + +Marks the beginning of a new shell prompt. + +**Format:** `A` + +**When:** Sent in `precmd` hook (after previous command completes, before new prompt is displayed) + +**Purpose:** Signals to the terminal that a new prompt is being drawn. This helps Wave Terminal distinguish between prompt output and command output. + +**Example:** +```bash +printf '\033]16162;A\007' +``` + +--- + +### C - Command Execution + +Sent immediately before a command is executed, optionally including the command text. + +**Format:** `C[;]` + +**Data Type:** +```typescript +{ + cmd64?: string; // base64-encoded command text +} +``` + +**When:** Sent in `preexec` hook (after user presses Enter, before command runs) + +**Purpose:** Notifies the terminal that a command is about to execute. The command text is base64-encoded to handle special characters safely. + +**Example:** +```bash +cmd64=$(printf '%s' "ls -la" | base64) +printf '\033]16162;C;{"cmd64":"%s"}\007' "$cmd64" +``` + +--- + +### M - Metadata + +Sends shell metadata information (typically only once at shell initialization). + +**Format:** `M;` + +**Data Type:** +```typescript +{ + shell?: string; // Shell name (e.g., "zsh", "bash") + shellversion?: string; // Version string of the shell + uname?: string; // Output of "uname -smr" (e.g., "Darwin 23.0.0 arm64") + integration?: boolean; // Whether shell integration is active (true) or disabled (false) +} +``` + +**When:** Sent during first `precmd` hook (on shell startup) + +**Purpose:** Provides Wave Terminal with information about the shell environment and operating system. + +**Example:** +```bash +uname_info=$(uname -smr 2>/dev/null) +printf '\033]16162;M;{"shell":"zsh","shellversion":"5.9","uname":"%s"}\007' "$uname_info" +``` + +--- + +### D - Done (Exit Status) + +Reports the exit status of the previously executed command. + +**Format:** `D;` + +**Data Type:** +```typescript +{ + exitcode?: number; // Exit status code of the previous command +} +``` + +**When:** Sent in `precmd` hook (after command completes) + +**Purpose:** Communicates whether the previous command succeeded or failed, allowing Wave Terminal to display success/failure indicators. + +**Example:** +```bash +# After command exits with status 0 +printf '\033]16162;D;{"exitcode":0}\007' + +# After command exits with status 1 +printf '\033]16162;D;{"exitcode":1}\007' +``` + +--- + +### I - Input Status + +Reports the current state of the command line input buffer. + +**Format:** `I;` + +**Data Type:** +```typescript +{ + inputempty?: boolean; // Whether the command line buffer is empty +} +``` + +**When:** Sent during ZLE (Zsh Line Editor) hooks when buffer state changes +- `zle-line-init` - When line editor is initialized +- `zle-line-pre-redraw` - Before line is redrawn + +**Purpose:** Allows Wave Terminal to track the state of the command line input. Currently reports whether the buffer is empty, but may be extended to include additional input state information in the future. + +**Example:** +```bash +# When buffer is empty +I;{"inputempty":true} + +# When buffer has content +I;{"inputempty":false} +``` + +### R - Reset Alternate Buffer + +Resets the terminal if it's in alternate buffer mode. + +**Format:** `R` + +**When:** Can be sent at any time to ensure terminal is not stuck in alternate buffer mode + +**Purpose:** If the terminal is currently displaying the alternate screen buffer, this command switches back to the normal buffer. This is useful for recovering from programs that crash without properly restoring the screen. + +**Behavior:** +- Checks if terminal is in alternate buffer mode (`terminal.buffer.active.type === "alternate"`) +- If in alternate mode, sends `ESC [ ? 1049 l` to exit alternate buffer +- If not in alternate mode, does nothing + +**Example:** +```bash +R +``` + +--- + +## Typical Command Flow + +Here's the typical sequence during shell interaction: + +``` +1. Shell starts + → M; (metadata - shell info) + +2. First prompt appears + → A (prompt start) + +3. User types command and presses Enter + → I;{"inputempty":false} (input no longer empty - sent as user types) + → C;{"cmd64":"..."} (command about to execute) + +4. Command runs and completes + → D;{"exitcode":} (exit status) + → I;{"inputempty":true} (input empty again) + → A (next prompt start) + +5. Repeat from step 3... +``` + +## Implementation Notes + +- Shell integration is **disabled** when running inside tmux or screen (`TMUX`, `STY` environment variables, or `tmux*`/`screen*` TERM values) +- Commands are base64-encoded in the C sequence to safely handle special characters, newlines, and control characters +- The I (input empty) command is only sent when the state changes (not on every keystroke) +- The M (metadata) command is only sent once during the first precmd +- The D (exit status) command is skipped during the first precmd (no previous command to report) + +## Related Files + +- [`pkg/util/shellutil/shellintegration/zsh_zshrc.sh`](pkg/util/shellutil/shellintegration/zsh_zshrc.sh) - Zsh shell integration implementation +- Similar integrations exist for bash and other shells + +## Standard OSC 7 + +Wave Terminal also uses the standard **OSC 7** sequence for reporting the current working directory: + +**Format:** `7;file://` + +This is sent: +- During first precmd (after metadata) +- In the `chpwd` hook (whenever directory changes) + +The path is URL-encoded to safely handle special characters. \ No newline at end of file diff --git a/aiprompts/wps-events.md b/aiprompts/wps-events.md new file mode 100644 index 000000000..2c7115c84 --- /dev/null +++ b/aiprompts/wps-events.md @@ -0,0 +1,296 @@ +# WPS Events Guide + +## Overview + +WPS (Wave PubSub) is Wave Terminal's publish-subscribe event system that enables different parts of the application to communicate asynchronously. The system uses a broker pattern to route events from publishers to subscribers based on event types and scopes. + +## Key Files + +- [`pkg/wps/wpstypes.go`](../pkg/wps/wpstypes.go) - Event type constants and data structures +- [`pkg/wps/wps.go`](../pkg/wps/wps.go) - Broker implementation and core logic +- [`pkg/wcore/wcore.go`](../pkg/wcore/wcore.go) - Example usage patterns + +## Event Structure + +Events in WPS have the following structure: + +```go +type WaveEvent struct { + Event string `json:"event"` // Event type constant + Scopes []string `json:"scopes,omitempty"` // Optional scopes for targeted delivery + Sender string `json:"sender,omitempty"` // Optional sender identifier + Persist int `json:"persist,omitempty"` // Number of events to persist in history + Data any `json:"data,omitempty"` // Event payload +} +``` + +## Adding a New Event Type + +### Step 1: Define the Event Constant + +Add your event type constant to [`pkg/wps/wpstypes.go`](../pkg/wps/wpstypes.go:8-19): + +```go +const ( + Event_BlockClose = "blockclose" + Event_ConnChange = "connchange" + // ... other events ... + Event_YourNewEvent = "your:newevent" // Use colon notation for namespacing +) +``` + +**Naming Convention:** + +- Use descriptive PascalCase for the constant name with `Event_` prefix +- Use lowercase with colons for the string value (e.g., "namespace:eventname") +- Group related events with the same namespace prefix + +### Step 2: Define Event Data Structure (Optional) + +If your event carries structured data, define a type for it: + +```go +type YourEventData struct { + Field1 string `json:"field1"` + Field2 int `json:"field2"` +} +``` + +### Step 3: Expose Type to Frontend (If Needed) + +If your event data type isn't already exposed via an RPC call, you need to add it to [`pkg/tsgen/tsgen.go`](../pkg/tsgen/tsgen.go:29-56) so TypeScript types are generated: + +```go +// add extra types to generate here +var ExtraTypes = []any{ + waveobj.ORef{}, + // ... other types ... + uctypes.RateLimitInfo{}, // Example: already added + YourEventData{}, // Add your new type here +} +``` + +Then run code generation: + +```bash +task generate +``` + +This will update [`frontend/types/gotypes.d.ts`](../frontend/types/gotypes.d.ts) with TypeScript definitions for your type, ensuring type safety in the frontend when handling these events. + +## Publishing Events + +### Basic Publishing + +To publish an event, use the global broker: + +```go +import "github.com/s-zx/crest/pkg/wps" + +wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_YourNewEvent, + Data: yourData, +}) +``` + +### Publishing with Scopes + +Scopes allow targeted event delivery. Subscribers can filter events by scope: + +```go +wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_WaveObjUpdate, + Scopes: []string{oref.String()}, // Target specific object + Data: updateData, +}) +``` + +### Publishing in a Goroutine + +To avoid blocking the caller, publish events asynchronously: + +```go +go func() { + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_YourNewEvent, + Data: data, + }) +}() +``` + +**When to use goroutines:** + +- When publishing from performance-critical code paths +- When the event is informational and doesn't need immediate delivery +- When publishing from code that holds locks (to prevent deadlocks) + +### Event Persistence + +Events can be persisted in memory for late subscribers: + +```go +wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_YourNewEvent, + Persist: 100, // Keep last 100 events + Data: data, +}) +``` + +## Complete Example: Rate Limit Updates + +This example shows how rate limit information is published when AI chat responses include rate limit headers. + +### 1. Define the Event Type + +In [`pkg/wps/wpstypes.go`](../pkg/wps/wpstypes.go:19): + +```go +const ( + // ... other events ... + Event_WaveAIRateLimit = "waveai:ratelimit" +) +``` + +### 2. Publish the Event + +In [`pkg/aiusechat/usechat.go`](../pkg/aiusechat/usechat.go:94-108): + +```go +import "github.com/s-zx/crest/pkg/wps" + +func updateRateLimit(info *uctypes.RateLimitInfo) { + if info == nil { + return + } + rateLimitLock.Lock() + defer rateLimitLock.Unlock() + globalRateLimitInfo = info + + // Publish event in goroutine to avoid blocking + go func() { + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_WaveAIRateLimit, + Data: info, // RateLimitInfo struct + }) + }() +} +``` + +### 3. Subscribe to the Event (Frontend) + +In the frontend, subscribe to events via WebSocket: + +```typescript +// Subscribe to rate limit updates +const subscription = { + event: "waveai:ratelimit", + allscopes: true, // Receive all rate limit events +}; +``` + +## Subscribing to Events + +### From Go Code + +```go +// Subscribe to all events of a type +wps.Broker.Subscribe(routeId, wps.SubscriptionRequest{ + Event: wps.Event_YourNewEvent, + AllScopes: true, +}) + +// Subscribe to specific scopes +wps.Broker.Subscribe(routeId, wps.SubscriptionRequest{ + Event: wps.Event_WaveObjUpdate, + Scopes: []string{"workspace:123"}, +}) + +// Unsubscribe +wps.Broker.Unsubscribe(routeId, wps.Event_YourNewEvent) +``` + +### Scope Matching + +Scopes support wildcard matching: + +- `*` matches a single scope segment +- `**` matches multiple scope segments + +```go +// Subscribe to all workspace events +wps.Broker.Subscribe(routeId, wps.SubscriptionRequest{ + Event: wps.Event_WaveObjUpdate, + Scopes: []string{"workspace:*"}, +}) +``` + +## Best Practices + +1. **Use Namespaces**: Prefix event names with a namespace (e.g., `waveai:`, `workspace:`, `block:`) + +2. **Don't Block**: Use goroutines when publishing from performance-critical code or while holding locks + +3. **Type-Safe Data**: Define struct types for event data rather than using maps + +4. **Scope Wisely**: Use scopes to limit event delivery and reduce unnecessary processing + +5. **Document Events**: Add comments explaining when events are fired and what data they carry + +6. **Consider Persistence**: Use `Persist` for events that late subscribers might need (like status updates). This is normally not used. We normally do a live RPC call to get the current value and then subscribe for updates. + +## Common Event Patterns + +### Status Updates + +```go +wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_ControllerStatus, + Scopes: []string{blockId}, + Persist: 1, // Keep only latest status + Data: statusData, +}) +``` + +### Object Updates + +```go +wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_WaveObjUpdate, + Scopes: []string{oref.String()}, + Data: waveobj.WaveObjUpdate{ + UpdateType: waveobj.UpdateType_Update, + OType: obj.GetOType(), + OID: waveobj.GetOID(obj), + Obj: obj, + }, +}) +``` + +### Batch Updates + +```go +// Helper function for multiple updates +func (b *BrokerType) SendUpdateEvents(updates waveobj.UpdatesRtnType) { + for _, update := range updates { + b.Publish(WaveEvent{ + Event: Event_WaveObjUpdate, + Scopes: []string{waveobj.MakeORef(update.OType, update.OID).String()}, + Data: update, + }) + } +} +``` + +## Debugging + +To debug event flow: + +1. Check broker subscription map: `wps.Broker.SubMap` +2. View persisted events: `wps.Broker.ReadEventHistory(eventType, scope, maxItems)` +3. Add logging in publish/subscribe methods +4. Monitor WebSocket traffic in browser dev tools + +## Related Documentation + +- [Configuration System](config-system.md) - Uses WPS events for config updates +- [Wave AI Architecture](waveai-architecture.md) - AI-related events diff --git a/assets/wave-dark.png b/assets/wave-dark.png new file mode 100644 index 000000000..e9cf7cb36 Binary files /dev/null and b/assets/wave-dark.png differ diff --git a/assets/wave-light.png b/assets/wave-light.png new file mode 100644 index 000000000..ab3d58b88 Binary files /dev/null and b/assets/wave-light.png differ diff --git a/assets/wave-screenshot.webp b/assets/wave-screenshot.webp new file mode 100644 index 000000000..372ff1700 Binary files /dev/null and b/assets/wave-screenshot.webp differ diff --git a/cmd/generatego/main-generatego.go b/cmd/generatego/main-generatego.go new file mode 100644 index 000000000..8f95a19fb --- /dev/null +++ b/cmd/generatego/main-generatego.go @@ -0,0 +1,98 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "os" + "reflect" + "strings" + + "github.com/s-zx/crest/pkg/gogen" + "github.com/s-zx/crest/pkg/util/utilfn" + "github.com/s-zx/crest/pkg/waveobj" + "github.com/s-zx/crest/pkg/wconfig" + "github.com/s-zx/crest/pkg/wshrpc" +) + +const WshClientFileName = "pkg/wshrpc/wshclient/wshclient.go" +const WaveObjMetaConstsFileName = "pkg/waveobj/metaconsts.go" +const SettingsMetaConstsFileName = "pkg/wconfig/metaconsts.go" + +func GenerateWshClient() error { + fmt.Fprintf(os.Stderr, "generating wshclient file to %s\n", WshClientFileName) + var buf strings.Builder + gogen.GenerateBoilerplate(&buf, "wshclient", []string{ + "github.com/s-zx/crest/pkg/baseds", + "github.com/s-zx/crest/pkg/cmdblock/cbtypes", + "github.com/s-zx/crest/pkg/telemetry/telemetrydata", + "github.com/s-zx/crest/pkg/vdom", + "github.com/s-zx/crest/pkg/waveobj", + "github.com/s-zx/crest/pkg/wconfig", + "github.com/s-zx/crest/pkg/wps", + "github.com/s-zx/crest/pkg/wshrpc", + "github.com/s-zx/crest/pkg/wshutil", + }) + wshDeclMap := wshrpc.GenerateWshCommandDeclMap() + for _, key := range utilfn.GetOrderedMapKeys(wshDeclMap) { + methodDecl := wshDeclMap[key] + if methodDecl.CommandType == wshrpc.RpcType_ResponseStream { + gogen.GenMethod_ResponseStream(&buf, methodDecl) + } else if methodDecl.CommandType == wshrpc.RpcType_Call { + gogen.GenMethod_Call(&buf, methodDecl) + } else { + panic("unsupported command type " + methodDecl.CommandType) + } + } + buf.WriteString("\n") + written, err := utilfn.WriteFileIfDifferent(WshClientFileName, []byte(buf.String())) + if !written { + fmt.Fprintf(os.Stderr, "no changes to %s\n", WshClientFileName) + } + return err +} + +func GenerateWaveObjMetaConsts() error { + fmt.Fprintf(os.Stderr, "generating waveobj meta consts file to %s\n", WaveObjMetaConstsFileName) + var buf strings.Builder + gogen.GenerateBoilerplate(&buf, "waveobj", []string{}) + gogen.GenerateMetaMapConsts(&buf, "MetaKey_", reflect.TypeOf(waveobj.MetaTSType{}), false) + buf.WriteString("\n") + written, err := utilfn.WriteFileIfDifferent(WaveObjMetaConstsFileName, []byte(buf.String())) + if !written { + fmt.Fprintf(os.Stderr, "no changes to %s\n", WaveObjMetaConstsFileName) + } + return err +} + +func GenerateSettingsMetaConsts() error { + fmt.Fprintf(os.Stderr, "generating settings meta consts file to %s\n", SettingsMetaConstsFileName) + var buf strings.Builder + gogen.GenerateBoilerplate(&buf, "wconfig", []string{}) + gogen.GenerateMetaMapConsts(&buf, "ConfigKey_", reflect.TypeOf(wconfig.SettingsType{}), false) + buf.WriteString("\n") + written, err := utilfn.WriteFileIfDifferent(SettingsMetaConstsFileName, []byte(buf.String())) + if !written { + fmt.Fprintf(os.Stderr, "no changes to %s\n", SettingsMetaConstsFileName) + } + return err +} + +func main() { + err := GenerateWshClient() + if err != nil { + fmt.Fprintf(os.Stderr, "error generating wshclient: %v\n", err) + return + } + err = GenerateWaveObjMetaConsts() + if err != nil { + fmt.Fprintf(os.Stderr, "error generating waveobj meta consts: %v\n", err) + return + } + err = GenerateSettingsMetaConsts() + if err != nil { + fmt.Fprintf(os.Stderr, "error generating settings meta consts: %v\n", err) + return + } +} diff --git a/cmd/generateschema/main-generateschema.go b/cmd/generateschema/main-generateschema.go new file mode 100644 index 000000000..8c9b7697f --- /dev/null +++ b/cmd/generateschema/main-generateschema.go @@ -0,0 +1,211 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "reflect" + + "github.com/invopop/jsonschema" + "github.com/s-zx/crest/pkg/util/utilfn" + "github.com/s-zx/crest/pkg/waveobj" + "github.com/s-zx/crest/pkg/wconfig" +) + +const WaveSchemaSettingsFileName = "schema/settings.json" +const WaveSchemaConnectionsFileName = "schema/connections.json" +const WaveSchemaWidgetsFileName = "schema/widgets.json" +const WaveSchemaBackgroundsFileName = "schema/backgrounds.json" +const WaveSchemaWaveAIFileName = "schema/waveai.json" + +// ViewNameType is a string type whose JSON Schema offers enum suggestions for the most +// common widget view names while still accepting any arbitrary string value. +type ViewNameType string + +func (ViewNameType) JSONSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + AnyOf: []*jsonschema.Schema{ + { + Enum: []any{"term", "preview", "web", "sysinfo", "launcher"}, + }, + { + Type: "string", + }, + }, + } +} + +// ControllerNameType is a string type whose JSON Schema offers enum suggestions for the +// known block controller names while still accepting any arbitrary string value. +type ControllerNameType string + +func (ControllerNameType) JSONSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + AnyOf: []*jsonschema.Schema{ + { + Enum: []any{"shell", "cmd"}, + }, + { + Type: "string", + }, + }, + } +} + +// WidgetsMetaSchemaHints provides schema hints for the blockdef.meta field in widget configs. +// It covers the most common keys used when defining widgets: view, file, url, controller, +// cmd and cmd:* options, and term:* options. +type WidgetsMetaSchemaHints struct { + View ViewNameType `json:"view,omitempty"` + File string `json:"file,omitempty"` + Url string `json:"url,omitempty"` + Controller ControllerNameType `json:"controller,omitempty"` + + Cmd string `json:"cmd,omitempty"` + CmdInteractive bool `json:"cmd:interactive,omitempty"` + CmdLogin bool `json:"cmd:login,omitempty"` + CmdPersistent bool `json:"cmd:persistent,omitempty"` + CmdRunOnStart bool `json:"cmd:runonstart,omitempty"` + CmdClearOnStart bool `json:"cmd:clearonstart,omitempty"` + CmdRunOnce bool `json:"cmd:runonce,omitempty"` + CmdCloseOnExit bool `json:"cmd:closeonexit,omitempty"` + CmdCloseOnExitForce bool `json:"cmd:closeonexitforce,omitempty"` + CmdCloseOnExitDelay float64 `json:"cmd:closeonexitdelay,omitempty"` + CmdNoWsh bool `json:"cmd:nowsh,omitempty"` + CmdArgs []string `json:"cmd:args,omitempty"` + CmdShell bool `json:"cmd:shell,omitempty"` + CmdAllowConnChange bool `json:"cmd:allowconnchange,omitempty"` + CmdEnv map[string]string `json:"cmd:env,omitempty"` + CmdCwd string `json:"cmd:cwd,omitempty"` + CmdInitScript string `json:"cmd:initscript,omitempty"` + CmdInitScriptSh string `json:"cmd:initscript.sh,omitempty"` + CmdInitScriptBash string `json:"cmd:initscript.bash,omitempty"` + CmdInitScriptZsh string `json:"cmd:initscript.zsh,omitempty"` + CmdInitScriptPwsh string `json:"cmd:initscript.pwsh,omitempty"` + CmdInitScriptFish string `json:"cmd:initscript.fish,omitempty"` + + TermFontSize int `json:"term:fontsize,omitempty"` + TermFontFamily string `json:"term:fontfamily,omitempty"` + TermMode string `json:"term:mode,omitempty"` + TermTheme string `json:"term:theme,omitempty"` + TermLocalShellPath string `json:"term:localshellpath,omitempty"` + TermLocalShellOpts []string `json:"term:localshellopts,omitempty"` + TermScrollback *int `json:"term:scrollback,omitempty"` + TermTransparency *float64 `json:"term:transparency,omitempty"` + TermAllowBracketedPaste *bool `json:"term:allowbracketedpaste,omitempty"` + TermShiftEnterNewline *bool `json:"term:shiftenternewline,omitempty"` + TermMacOptionIsMeta *bool `json:"term:macoptionismeta,omitempty"` + TermBellSound *bool `json:"term:bellsound,omitempty"` + TermBellIndicator *bool `json:"term:bellindicator,omitempty"` + TermDurable *bool `json:"term:durable,omitempty"` +} + +// allowNullValues wraps the top-level additionalProperties of a map schema with +// anyOf: [originalSchema, {type: "null"}] so that setting a key to null is valid +// (e.g. "bg@foo": null to remove a default entry). +func allowNullValues(schema *jsonschema.Schema) { + if schema.AdditionalProperties != nil && schema.AdditionalProperties != jsonschema.TrueSchema && schema.AdditionalProperties != jsonschema.FalseSchema { + original := schema.AdditionalProperties + schema.AdditionalProperties = &jsonschema.Schema{ + AnyOf: []*jsonschema.Schema{ + original, + {Type: "null"}, + }, + } + } +} + +func generateSchema(template any, dir string, allowNull bool) error { + settingsSchema := jsonschema.Reflect(template) + if allowNull { + allowNullValues(settingsSchema) + } + + jsonSettingsSchema, err := json.MarshalIndent(settingsSchema, "", " ") + if err != nil { + return fmt.Errorf("failed to parse local schema: %w", err) + } + written, err := utilfn.WriteFileIfDifferent(dir, jsonSettingsSchema) + if !written { + fmt.Fprintf(os.Stderr, "no changes to %s\n", dir) + } + if err != nil { + return fmt.Errorf("failed to write local schema: %w", err) + } + return nil +} + +func generateWidgetsSchema(dir string) error { + metaT := reflect.TypeOf(waveobj.MetaMapType(nil)) + + // Build the hints schema once using an expanded reflector + hr := &jsonschema.Reflector{ + DoNotReference: true, + ExpandedStruct: true, + AllowAdditionalProperties: true, + } + hintSchema := hr.Reflect(&WidgetsMetaSchemaHints{}) + + r := &jsonschema.Reflector{} + r.Mapper = func(t reflect.Type) *jsonschema.Schema { + if t == metaT { + return &jsonschema.Schema{ + Type: "object", + Properties: hintSchema.Properties, + AdditionalProperties: jsonschema.TrueSchema, + } + } + return nil + } + + widgetsTemplate := make(map[string]wconfig.WidgetConfigType) + widgetsSchema := r.Reflect(&widgetsTemplate) + allowNullValues(widgetsSchema) + + jsonWidgetsSchema, err := json.MarshalIndent(widgetsSchema, "", " ") + if err != nil { + return fmt.Errorf("failed to parse widgets schema: %w", err) + } + written, err := utilfn.WriteFileIfDifferent(dir, jsonWidgetsSchema) + if !written { + fmt.Fprintf(os.Stderr, "no changes to %s\n", dir) + } + if err != nil { + return fmt.Errorf("failed to write widgets schema: %w", err) + } + return nil +} + +func main() { + err := generateSchema(&wconfig.SettingsType{}, WaveSchemaSettingsFileName, false) + if err != nil { + log.Fatalf("settings schema error: %v", err) + } + + connectionTemplate := make(map[string]wconfig.ConnKeywords) + err = generateSchema(&connectionTemplate, WaveSchemaConnectionsFileName, false) + if err != nil { + log.Fatalf("connections schema error: %v", err) + } + + err = generateWidgetsSchema(WaveSchemaWidgetsFileName) + if err != nil { + log.Fatalf("widgets schema error: %v", err) + } + + backgroundsTemplate := make(map[string]wconfig.BackgroundConfigType) + err = generateSchema(&backgroundsTemplate, WaveSchemaBackgroundsFileName, true) + if err != nil { + log.Fatalf("backgrounds schema error: %v", err) + } + + // waveai.json schema generation removed in Phase E of the ai-config + // refactor — AI config now lives in ~/.config/crest/ai.json + // (pkg/aiusechat/uctypes.AIUserConfig). A dedicated ai.json schema + // generator can be added later if we want jsonschema validation in + // the user's editor; v1 relies on the Go decoder's own checks. +} diff --git a/cmd/generatets/main-generatets.go b/cmd/generatets/main-generatets.go new file mode 100644 index 000000000..bed8717cb --- /dev/null +++ b/cmd/generatets/main-generatets.go @@ -0,0 +1,191 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "fmt" + "os" + "reflect" + "sort" + "strings" + + "github.com/s-zx/crest/pkg/service" + "github.com/s-zx/crest/pkg/tsgen" + "github.com/s-zx/crest/pkg/util/utilfn" + "github.com/s-zx/crest/pkg/wshrpc" +) + +func generateTypesFile(tsTypesMap map[reflect.Type]string) error { + fileName := "frontend/types/gotypes.d.ts" + fmt.Fprintf(os.Stderr, "generating types file to %s\n", fileName) + tsgen.GenerateWaveObjTypes(tsTypesMap) + tsgen.GenerateWaveEventTypes(tsTypesMap) + err := tsgen.GenerateServiceTypes(tsTypesMap) + if err != nil { + fmt.Fprintf(os.Stderr, "Error generating service types: %v\n", err) + os.Exit(1) + } + err = tsgen.GenerateWshServerTypes(tsTypesMap) + if err != nil { + return fmt.Errorf("error generating wsh server types: %w", err) + } + var buf bytes.Buffer + fmt.Fprintf(&buf, "// Copyright 2026, Command Line Inc.\n") + fmt.Fprintf(&buf, "// SPDX-License-Identifier: Apache-2.0\n\n") + fmt.Fprintf(&buf, "// generated by cmd/generate/main-generatets.go\n\n") + fmt.Fprintf(&buf, "declare global {\n\n") + var keys []reflect.Type + for key := range tsTypesMap { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + iname, _ := tsgen.TypeToTSType(keys[i], tsTypesMap) + jname, _ := tsgen.TypeToTSType(keys[j], tsTypesMap) + return iname < jname + }) + for _, key := range keys { + // don't output generic types + if strings.Contains(key.Name(), "[") { + continue + } + tsCode := tsTypesMap[key] + istr := utilfn.IndentString(" ", tsCode) + fmt.Fprint(&buf, istr) + } + fmt.Fprintf(&buf, "}\n\n") + fmt.Fprintf(&buf, "export {}\n") + written, err := utilfn.WriteFileIfDifferent(fileName, buf.Bytes()) + if !written { + fmt.Fprintf(os.Stderr, "no changes to %s\n", fileName) + } + return err +} + +func generateWaveEventFile(tsTypesMap map[reflect.Type]string) error { + fileName := "frontend/types/waveevent.d.ts" + fmt.Fprintf(os.Stderr, "generating waveevent file to %s\n", fileName) + var buf bytes.Buffer + fmt.Fprintf(&buf, "// Copyright 2026, Command Line Inc.\n") + fmt.Fprintf(&buf, "// SPDX-License-Identifier: Apache-2.0\n\n") + fmt.Fprintf(&buf, "// generated by cmd/generate/main-generatets.go\n\n") + fmt.Fprintf(&buf, "declare global {\n\n") + fmt.Fprint(&buf, utilfn.IndentString(" ", tsgen.GenerateWaveEventTypes(tsTypesMap))) + fmt.Fprintf(&buf, "}\n\n") + fmt.Fprintf(&buf, "export {}\n") + written, err := utilfn.WriteFileIfDifferent(fileName, buf.Bytes()) + if !written { + fmt.Fprintf(os.Stderr, "no changes to %s\n", fileName) + } + return err +} + +func generateServicesFile(tsTypesMap map[reflect.Type]string) error { + fileName := "frontend/app/store/services.ts" + var buf bytes.Buffer + fmt.Fprintf(os.Stderr, "generating services file to %s\n", fileName) + fmt.Fprintf(&buf, "// Copyright 2026, Command Line Inc.\n") + fmt.Fprintf(&buf, "// SPDX-License-Identifier: Apache-2.0\n\n") + fmt.Fprintf(&buf, "// generated by cmd/generate/main-generatets.go\n\n") + fmt.Fprintf(&buf, "import * as WOS from \"./wos\";\n") + fmt.Fprintf(&buf, "import type { WaveEnv } from \"@/app/waveenv/waveenv\";\n\n") + fmt.Fprintf(&buf, "function callBackendService(waveEnv: WaveEnv, service: string, method: string, args: any[], noUIContext?: boolean): Promise {\n") + fmt.Fprintf(&buf, " if (waveEnv != null) {\n") + fmt.Fprintf(&buf, " return waveEnv.callBackendService(service, method, args, noUIContext)\n") + fmt.Fprintf(&buf, " }\n") + fmt.Fprintf(&buf, " return WOS.callBackendService(service, method, args, noUIContext);\n") + fmt.Fprintf(&buf, "}\n\n") + orderedKeys := utilfn.GetOrderedMapKeys(service.ServiceMap) + for _, serviceName := range orderedKeys { + serviceObj := service.ServiceMap[serviceName] + svcStr := tsgen.GenerateServiceClass(serviceName, serviceObj, tsTypesMap) + fmt.Fprint(&buf, svcStr) + fmt.Fprint(&buf, "\n") + } + fmt.Fprintf(&buf, "export const AllServiceTypes = {\n") + for _, serviceName := range orderedKeys { + serviceObj := service.ServiceMap[serviceName] + serviceType := reflect.TypeOf(serviceObj) + tsServiceName := serviceType.Elem().Name() + fmt.Fprintf(&buf, " %q: %sType,\n", serviceName, tsServiceName) + } + fmt.Fprintf(&buf, "};\n\n") + fmt.Fprintf(&buf, "export const AllServiceImpls = {\n") + for _, serviceName := range orderedKeys { + serviceObj := service.ServiceMap[serviceName] + serviceType := reflect.TypeOf(serviceObj) + tsServiceName := serviceType.Elem().Name() + fmt.Fprintf(&buf, " %q: %s,\n", serviceName, tsServiceName) + } + fmt.Fprintf(&buf, "};\n") + written, err := utilfn.WriteFileIfDifferent(fileName, buf.Bytes()) + if !written { + fmt.Fprintf(os.Stderr, "no changes to %s\n", fileName) + } + return err +} + +func generateWshClientApiFile(tsTypeMap map[reflect.Type]string) error { + fileName := "frontend/app/store/wshclientapi.ts" + var buf bytes.Buffer + declMap := wshrpc.GenerateWshCommandDeclMap() + fmt.Fprintf(os.Stderr, "generating wshclientapi file to %s\n", fileName) + fmt.Fprintf(&buf, "// Copyright 2026, Command Line Inc.\n") + fmt.Fprintf(&buf, "// SPDX-License-Identifier: Apache-2.0\n\n") + fmt.Fprintf(&buf, "// generated by cmd/generate/main-generatets.go\n\n") + fmt.Fprintf(&buf, "import { WshClient } from \"./wshclient\";\n\n") + fmt.Fprintf(&buf, "export interface MockRpcClient {\n") + fmt.Fprintf(&buf, " mockWshRpcCall(client: WshClient, command: string, data: any, opts?: RpcOpts): Promise;\n") + fmt.Fprintf(&buf, " mockWshRpcStream(client: WshClient, command: string, data: any, opts?: RpcOpts): AsyncGenerator;\n") + fmt.Fprintf(&buf, "}\n\n") + orderedKeys := utilfn.GetOrderedMapKeys(declMap) + fmt.Fprintf(&buf, "// WshServerCommandToDeclMap\n") + fmt.Fprintf(&buf, "export class RpcApiType {\n") + fmt.Fprintf(&buf, " mockClient: MockRpcClient = null;\n\n") + fmt.Fprintf(&buf, " setMockRpcClient(client: MockRpcClient): void {\n") + fmt.Fprintf(&buf, " this.mockClient = client;\n") + fmt.Fprintf(&buf, " }\n\n") + for _, methodDecl := range orderedKeys { + methodDecl := declMap[methodDecl] + methodStr := tsgen.GenerateWshClientApiMethod(methodDecl, tsTypeMap) + fmt.Fprint(&buf, methodStr) + fmt.Fprintf(&buf, "\n") + } + fmt.Fprintf(&buf, "}\n\n") + fmt.Fprintf(&buf, "export const RpcApi = new RpcApiType();\n") + written, err := utilfn.WriteFileIfDifferent(fileName, buf.Bytes()) + if !written { + fmt.Fprintf(os.Stderr, "no changes to %s\n", fileName) + } + return err +} + +func main() { + err := service.ValidateServiceMap() + if err != nil { + fmt.Fprintf(os.Stderr, "Error validating service map: %v\n", err) + os.Exit(1) + } + tsTypesMap := make(map[reflect.Type]string) + err = generateTypesFile(tsTypesMap) + if err != nil { + fmt.Fprintf(os.Stderr, "Error generating types file: %v\n", err) + os.Exit(1) + } + err = generateServicesFile(tsTypesMap) + if err != nil { + fmt.Fprintf(os.Stderr, "Error generating services file: %v\n", err) + os.Exit(1) + } + err = generateWaveEventFile(tsTypesMap) + if err != nil { + fmt.Fprintf(os.Stderr, "Error generating wave event file: %v\n", err) + os.Exit(1) + } + err = generateWshClientApiFile(tsTypesMap) + if err != nil { + fmt.Fprintf(os.Stderr, "Error generating wshserver file: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/packfiles/main-packfiles.go b/cmd/packfiles/main-packfiles.go new file mode 100644 index 000000000..ac0fd52ac --- /dev/null +++ b/cmd/packfiles/main-packfiles.go @@ -0,0 +1,84 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bufio" + "fmt" + "io" + "os" + "path/filepath" +) + +func main() { + // Ensure at least one argument is provided + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "Usage: go run main.go ...") + os.Exit(1) + } + + // Get the current working directory + cwd, err := os.Getwd() + if err != nil { + fmt.Fprintf(os.Stderr, "Error getting current working directory: %v\n", err) + os.Exit(1) + } + + for _, filePath := range os.Args[1:] { + if filePath == "" || filePath == "--" { + continue + } + // Convert file path to an absolute path + absPath, err := filepath.Abs(filePath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving absolute path for %q: %v\n", filePath, err) + continue + } + + finfo, err := os.Stat(absPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error getting file info for %q: %v\n", absPath, err) + continue + } + if finfo.IsDir() { + fmt.Fprintf(os.Stderr, "%q is a directory, skipping\n", absPath) + continue + } + + // Get the path relative to the current working directory + relPath, err := filepath.Rel(cwd, absPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving relative path for %q: %v\n", absPath, err) + continue + } + + // Open the file + file, err := os.Open(absPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error opening file %q: %v\n", absPath, err) + continue + } + defer file.Close() + + // Print start delimiter with quoted relative path + fmt.Printf("@@@start file %q\n", relPath) + + // Copy file contents to stdout + reader := bufio.NewReader(file) + for { + line, err := reader.ReadString('\n') + fmt.Print(line) // Print each line + if err == io.EOF { + break + } + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading file %q: %v\n", relPath, err) + break + } + } + + // Print end delimiter with quoted relative path + fmt.Printf("@@@end file %q\n", relPath) + } +} diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go new file mode 100644 index 000000000..1b3e04af3 --- /dev/null +++ b/cmd/server/main-server.go @@ -0,0 +1,594 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "fmt" + "log" + "os" + + "runtime" + "sync" + "time" + + "github.com/joho/godotenv" + "github.com/s-zx/crest/pkg/authkey" + "github.com/s-zx/crest/pkg/blockcontroller" + "github.com/s-zx/crest/pkg/blocklogger" + "github.com/s-zx/crest/pkg/filebackup" + "github.com/s-zx/crest/pkg/filestore" + "github.com/s-zx/crest/pkg/jobcontroller" + "github.com/s-zx/crest/pkg/panichandler" + "github.com/s-zx/crest/pkg/remote/conncontroller" + "github.com/s-zx/crest/pkg/remote/fileshare/wshfs" + "github.com/s-zx/crest/pkg/secretstore" + "github.com/s-zx/crest/pkg/service" + "github.com/s-zx/crest/pkg/telemetry" + "github.com/s-zx/crest/pkg/telemetry/telemetrydata" + "github.com/s-zx/crest/pkg/util/envutil" + "github.com/s-zx/crest/pkg/util/shellutil" + "github.com/s-zx/crest/pkg/util/sigutil" + "github.com/s-zx/crest/pkg/util/utilfn" + "github.com/s-zx/crest/pkg/wavebase" + "github.com/s-zx/crest/pkg/waveobj" + "github.com/s-zx/crest/pkg/wconfig" + "github.com/s-zx/crest/pkg/wcore" + "github.com/s-zx/crest/pkg/web" + "github.com/s-zx/crest/pkg/wps" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" + "github.com/s-zx/crest/pkg/wshrpc/wshremote" + "github.com/s-zx/crest/pkg/wshrpc/wshserver" + "github.com/s-zx/crest/pkg/wshutil" + "github.com/s-zx/crest/pkg/wslconn" + "github.com/s-zx/crest/pkg/wstore" + + "net/http" + _ "net/http/pprof" +) + +// these are set at build time +var WaveVersion = "0.0.0" +var BuildTime = "0" + +const InitialTelemetryWait = 10 * time.Second +const TelemetryTick = 2 * time.Minute +const TelemetryInterval = 4 * time.Hour +const TelemetryInitialCountsWait = 5 * time.Second +const TelemetryCountsInterval = 1 * time.Hour +const BackupCleanupTick = 2 * time.Minute +const BackupCleanupInterval = 4 * time.Hour +const InitialDiagnosticWait = 5 * time.Minute +const DiagnosticTick = 10 * time.Minute + +var shutdownOnce sync.Once + +func init() { + envFilePath := os.Getenv("WAVETERM_ENVFILE") + if envFilePath != "" { + log.Printf("applying env file: %s\n", envFilePath) + _ = godotenv.Load(envFilePath) + } +} + +func doShutdown(reason string) { + shutdownOnce.Do(func() { + log.Printf("shutting down: %s\n", reason) + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + go blockcontroller.StopAllBlockControllersForShutdown() + shutdownActivityUpdate() + sendTelemetryWrapper() + // TODO deal with flush in progress + clearTempFiles() + filestore.WFS.FlushCache(ctx) + watcher := wconfig.GetWatcher() + if watcher != nil { + watcher.Close() + } + time.Sleep(500 * time.Millisecond) + log.Printf("shutdown complete\n") + os.Exit(0) + }) +} + +// watch stdin, kill server if stdin is closed +func stdinReadWatch() { + defer func() { + panichandler.PanicHandler("stdinReadWatch", recover()) + }() + buf := make([]byte, 1024) + for { + _, err := os.Stdin.Read(buf) + if err != nil { + doShutdown(fmt.Sprintf("stdin closed/error (%v)", err)) + break + } + } +} + +func startConfigWatcher() { + watcher := wconfig.GetWatcher() + if watcher != nil { + watcher.Start() + } +} + +func telemetryLoop() { + defer func() { + panichandler.PanicHandler("telemetryLoop", recover()) + }() + var nextSend int64 + time.Sleep(InitialTelemetryWait) + for { + if time.Now().Unix() > nextSend { + nextSend = time.Now().Add(TelemetryInterval).Unix() + sendTelemetryWrapper() + } + time.Sleep(TelemetryTick) + } +} + +func diagnosticLoop() { + defer func() { + panichandler.PanicHandler("diagnosticLoop", recover()) + }() + if os.Getenv("WAVETERM_NOPING") != "" { + log.Printf("WAVETERM_NOPING set, disabling diagnostic ping\n") + return + } + var lastSentDate string + time.Sleep(InitialDiagnosticWait) + for { + currentDate := time.Now().Format("2006-01-02") + if lastSentDate == "" || lastSentDate != currentDate { + if sendDiagnosticPing() { + lastSentDate = currentDate + } + } + time.Sleep(DiagnosticTick) + } +} + +func sendDiagnosticPing() bool { + rpcClient := wshclient.GetBareRpcClient() + isOnline, err := wshclient.NetworkOnlineCommand(rpcClient, &wshrpc.RpcOpts{Route: "electron", Timeout: 2000}) + if err != nil || !isOnline { + return false + } + return true +} + +func setupTelemetryConfigHandler() { + watcher := wconfig.GetWatcher() + if watcher == nil { + return + } + currentConfig := watcher.GetFullConfig() + currentTelemetryEnabled := currentConfig.Settings.TelemetryEnabled + + watcher.RegisterUpdateHandler(func(newConfig wconfig.FullConfigType) { + newTelemetryEnabled := newConfig.Settings.TelemetryEnabled + if newTelemetryEnabled != currentTelemetryEnabled { + currentTelemetryEnabled = newTelemetryEnabled + } + }) +} + +func backupCleanupLoop() { + defer func() { + panichandler.PanicHandler("backupCleanupLoop", recover()) + }() + var nextCleanup int64 + for { + if time.Now().Unix() > nextCleanup { + nextCleanup = time.Now().Add(BackupCleanupInterval).Unix() + err := filebackup.CleanupOldBackups() + if err != nil { + log.Printf("error cleaning up old backups: %v\n", err) + } + } + time.Sleep(BackupCleanupTick) + } +} + +func panicTelemetryHandler(panicName string) { + activity := wshrpc.ActivityUpdate{NumPanics: 1} + err := telemetry.UpdateActivity(context.Background(), activity) + if err != nil { + log.Printf("error updating activity (panicTelemetryHandler): %v\n", err) + } + telemetry.RecordTEvent(context.Background(), telemetrydata.MakeTEvent("debug:panic", telemetrydata.TEventProps{ + PanicType: panicName, + })) +} + +func sendTelemetryWrapper() { + defer func() { + panichandler.PanicHandler("sendTelemetryWrapper", recover()) + }() + ctx, cancelFn := context.WithTimeout(context.Background(), 15*time.Second) + defer cancelFn() + beforeSendActivityUpdate(ctx) +} + +func updateTelemetryCounts(lastCounts telemetrydata.TEventProps) telemetrydata.TEventProps { + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + var props telemetrydata.TEventProps + props.CountBlocks, _ = wstore.DBGetCount[*waveobj.Block](ctx) + props.CountTabs, _ = wstore.DBGetCount[*waveobj.Tab](ctx) + props.CountWindows, _ = wstore.DBGetCount[*waveobj.Window](ctx) + props.CountWorkspaces, _, _ = wstore.DBGetWSCounts(ctx) + props.CountSSHConn = conncontroller.GetNumSSHHasConnected() + props.CountWSLConn = wslconn.GetNumWSLHasConnected() + props.CountJobs = jobcontroller.GetNumJobsRunning() + props.CountJobsConnected = jobcontroller.GetNumJobsConnected() + props.CountViews, _ = wstore.DBGetBlockViewCounts(ctx) + + fullConfig := wconfig.GetWatcher().GetFullConfig() + customWidgets := fullConfig.CountCustomWidgets() + customSettings := wconfig.CountCustomSettings() + customAIModes := fullConfig.CountCustomAIModes() + + props.UserSet = &telemetrydata.TEventUserProps{ + SettingsCustomWidgets: customWidgets, + SettingsCustomSettings: customSettings, + SettingsCustomAIModes: customAIModes, + } + + secretsCount, err := secretstore.CountSecrets() + if err == nil { + props.UserSet.SettingsSecretsCount = secretsCount + } + + if utilfn.CompareAsMarshaledJson(props, lastCounts) { + return lastCounts + } + tevent := telemetrydata.MakeTEvent("app:counts", props) + err = telemetry.RecordTEvent(ctx, tevent) + if err != nil { + log.Printf("error recording counts tevent: %v\n", err) + } + return props +} + +func updateTelemetryCountsLoop() { + defer func() { + panichandler.PanicHandler("updateTelemetryCountsLoop", recover()) + }() + var nextSend int64 + var lastCounts telemetrydata.TEventProps + time.Sleep(TelemetryInitialCountsWait) + for { + if time.Now().Unix() > nextSend { + nextSend = time.Now().Add(TelemetryCountsInterval).Unix() + lastCounts = updateTelemetryCounts(lastCounts) + } + time.Sleep(TelemetryTick) + } +} + +func beforeSendActivityUpdate(ctx context.Context) { + activity := wshrpc.ActivityUpdate{} + activity.NumTabs, _ = wstore.DBGetCount[*waveobj.Tab](ctx) + activity.NumBlocks, _ = wstore.DBGetCount[*waveobj.Block](ctx) + activity.Blocks, _ = wstore.DBGetBlockViewCounts(ctx) + activity.NumWindows, _ = wstore.DBGetCount[*waveobj.Window](ctx) + activity.NumSSHConn = conncontroller.GetNumSSHHasConnected() + activity.NumWSLConn = wslconn.GetNumWSLHasConnected() + activity.NumWSNamed, activity.NumWS, _ = wstore.DBGetWSCounts(ctx) + err := telemetry.UpdateActivity(ctx, activity) + if err != nil { + log.Printf("error updating before activity: %v\n", err) + } +} + +func startupActivityUpdate(firstLaunch bool) { + defer func() { + panichandler.PanicHandler("startupActivityUpdate", recover()) + }() + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + activity := wshrpc.ActivityUpdate{Startup: 1} + err := telemetry.UpdateActivity(ctx, activity) // set at least one record into activity (don't use go routine wrap here) + if err != nil { + log.Printf("error updating startup activity: %v\n", err) + } + autoUpdateChannel := telemetry.AutoUpdateChannel() + autoUpdateEnabled := telemetry.IsAutoUpdateEnabled() + shellType, shellVersion, shellErr := shellutil.DetectShellTypeAndVersion() + if shellErr != nil { + shellType = "error" + shellVersion = "" + } + userSetOnce := &telemetrydata.TEventUserProps{ + ClientInitialVersion: "v" + WaveVersion, + } + tosTs := telemetry.GetTosAgreedTs() + var cohortTime time.Time + if tosTs > 0 { + cohortTime = time.UnixMilli(tosTs) + } else { + cohortTime = time.Now() + } + cohortMonth := cohortTime.Format("2006-01") + year, week := cohortTime.ISOWeek() + cohortISOWeek := fmt.Sprintf("%04d-W%02d", year, week) + userSetOnce.CohortMonth = cohortMonth + userSetOnce.CohortISOWeek = cohortISOWeek + fullConfig := wconfig.GetWatcher().GetFullConfig() + props := telemetrydata.TEventProps{ + UserSet: &telemetrydata.TEventUserProps{ + ClientVersion: "v" + wavebase.WaveVersion, + ClientBuildTime: wavebase.BuildTime, + ClientArch: wavebase.ClientArch(), + ClientOSRelease: wavebase.UnameKernelRelease(), + ClientIsDev: wavebase.IsDevMode(), + ClientPackageType: wavebase.ClientPackageType(), + ClientMacOSVersion: wavebase.ClientMacOSVersion(), + AutoUpdateChannel: autoUpdateChannel, + AutoUpdateEnabled: autoUpdateEnabled, + LocalShellType: shellType, + LocalShellVersion: shellVersion, + SettingsTransparent: fullConfig.Settings.WindowTransparent, + }, + UserSetOnce: userSetOnce, + } + if firstLaunch { + props.AppFirstLaunch = true + } + tevent := telemetrydata.MakeTEvent("app:startup", props) + err = telemetry.RecordTEvent(ctx, tevent) + if err != nil { + log.Printf("error recording startup event: %v\n", err) + } +} + +func shutdownActivityUpdate() { + ctx, cancelFn := context.WithTimeout(context.Background(), 1*time.Second) + defer cancelFn() + activity := wshrpc.ActivityUpdate{Shutdown: 1} + err := telemetry.UpdateActivity(ctx, activity) // do NOT use the go routine wrap here (this needs to be synchronous) + if err != nil { + log.Printf("error updating shutdown activity: %v\n", err) + } + err = telemetry.TruncateActivityTEventForShutdown(ctx) + if err != nil { + log.Printf("error truncating activity t-event for shutdown: %v\n", err) + } + tevent := telemetrydata.MakeTEvent("app:shutdown", telemetrydata.TEventProps{}) + err = telemetry.RecordTEvent(ctx, tevent) + if err != nil { + log.Printf("error recording shutdown event: %v\n", err) + } +} + +func createMainWshClient() { + rpc := wshserver.GetMainRpcClient() + wshutil.DefaultRouter.RegisterTrustedLeaf(rpc, wshutil.DefaultRoute) + wps.Broker.SetClient(wshutil.DefaultRouter) + localInitialEnv := envutil.PruneInitialEnv(envutil.SliceToMap(os.Environ())) + sockName := wavebase.GetDomainSocketName() + remoteImpl := wshremote.MakeRemoteRpcServerImpl(nil, wshutil.DefaultRouter, wshclient.GetBareRpcClient(), true, localInitialEnv, sockName) + localConnWsh := wshutil.MakeWshRpc(wshrpc.RpcContext{Conn: wshrpc.LocalConnName}, remoteImpl, "conn:local") + go wshremote.RunSysInfoLoop(localConnWsh, wshrpc.LocalConnName) + wshutil.DefaultRouter.RegisterTrustedLeaf(localConnWsh, wshutil.MakeConnectionRouteId(wshrpc.LocalConnName)) + wshfs.RpcClient = localConnWsh + wshfs.RpcClientRouteId = wshutil.MakeConnectionRouteId(wshrpc.LocalConnName) +} + +func grabAndRemoveEnvVars() error { + err := authkey.SetAuthKeyFromEnv() + if err != nil { + return fmt.Errorf("setting auth key: %v", err) + } + err = wavebase.CacheAndRemoveEnvVars() + if err != nil { + return err + } + // Remove WAVETERM env vars that leak from prod => dev + os.Unsetenv("WAVETERM_CLIENTID") + os.Unsetenv("WAVETERM_WORKSPACEID") + os.Unsetenv("WAVETERM_TABID") + os.Unsetenv("WAVETERM_BLOCKID") + os.Unsetenv("WAVETERM_CONN") + os.Unsetenv("WAVETERM_JWT") + os.Unsetenv("WAVETERM_VERSION") + + return nil +} + +func clearTempFiles() error { + ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) + defer cancelFn() + client, err := wstore.DBGetSingleton[*waveobj.Client](ctx) + if err != nil { + return fmt.Errorf("error getting client: %v", err) + } + filestore.WFS.DeleteZone(ctx, client.TempOID) + return nil +} + +func maybeStartPprofServer() { + settings := wconfig.GetWatcher().GetFullConfig().Settings + if settings.DebugPprofMemProfileRate != nil { + runtime.MemProfileRate = *settings.DebugPprofMemProfileRate + log.Printf("set runtime.MemProfileRate to %d\n", runtime.MemProfileRate) + } + if settings.DebugPprofPort == nil { + return + } + pprofPort := *settings.DebugPprofPort + if pprofPort < 1 || pprofPort > 65535 { + log.Printf("[error] debug:pprofport must be between 1 and 65535, got %d\n", pprofPort) + return + } + go func() { + addr := fmt.Sprintf("localhost:%d", pprofPort) + log.Printf("starting pprof server on %s\n", addr) + if err := http.ListenAndServe(addr, nil); err != nil { + log.Printf("[error] pprof server failed: %v\n", err) + } + }() +} + +func main() { + log.SetFlags(0) // disable timestamp since electron's winston logger already wraps with timestamp + log.SetPrefix("[wavesrv] ") + wavebase.WaveVersion = WaveVersion + wavebase.BuildTime = BuildTime + wshutil.DefaultRouter = wshutil.NewWshRouter() + wshutil.DefaultRouter.SetAsRootRouter() + + err := grabAndRemoveEnvVars() + if err != nil { + log.Printf("[error] %v\n", err) + return + } + err = service.ValidateServiceMap() + if err != nil { + log.Printf("error validating service map: %v\n", err) + return + } + err = wavebase.EnsureWaveDataDir() + if err != nil { + log.Printf("error ensuring wave home dir: %v\n", err) + return + } + err = wavebase.EnsureWaveDBDir() + if err != nil { + log.Printf("error ensuring wave db dir: %v\n", err) + return + } + err = wavebase.EnsureWaveConfigDir() + if err != nil { + log.Printf("error ensuring wave config dir: %v\n", err) + return + } + + // TODO: rather than ensure this dir exists, we should let the editor recursively create parent dirs on save + err = wavebase.EnsureWavePresetsDir() + if err != nil { + log.Printf("error ensuring wave presets dir: %v\n", err) + return + } + err = wavebase.EnsureWaveCachesDir() + if err != nil { + log.Printf("error ensuring wave caches dir: %v\n", err) + return + } + waveLock, err := wavebase.AcquireWaveLock() + if err != nil { + log.Printf("error acquiring wave lock (another instance of Wave is likely running): %v\n", err) + return + } + defer func() { + err = waveLock.Close() + if err != nil { + log.Printf("error releasing wave lock: %v\n", err) + } + }() + log.Printf("wave version: %s (%s)\n", WaveVersion, BuildTime) + log.Printf("wave data dir: %s\n", wavebase.GetWaveDataDir()) + log.Printf("wave config dir: %s\n", wavebase.GetWaveConfigDir()) + err = filestore.InitFilestore() + if err != nil { + log.Printf("error initializing filestore: %v\n", err) + return + } + err = wstore.InitWStore() + if err != nil { + log.Printf("error initializing wstore: %v\n", err) + return + } + panichandler.PanicTelemetryHandler = panicTelemetryHandler + go func() { + defer func() { + panichandler.PanicHandler("InitCustomShellStartupFiles", recover()) + }() + err := shellutil.InitCustomShellStartupFiles() + if err != nil { + log.Printf("error initializing wsh and shell-integration files: %v\n", err) + } + }() + firstLaunch, err := wcore.EnsureInitialData() + if err != nil { + log.Printf("error ensuring initial data: %v\n", err) + return + } + if firstLaunch { + log.Printf("first launch detected") + } + err = clearTempFiles() + if err != nil { + log.Printf("error clearing temp files: %v\n", err) + return + } + err = wcore.InitMainServer() + if err != nil { + log.Printf("error initializing mainserver: %v\n", err) + return + } + + err = shellutil.FixupWaveZshHistory() + if err != nil { + log.Printf("error fixing up wave zsh history: %v\n", err) + } + createMainWshClient() + sigutil.InstallShutdownSignalHandlers(doShutdown) + sigutil.InstallSIGUSR1Handler() + wconfig.MigratePresetsBackgrounds() + startConfigWatcher() + maybeStartPprofServer() + go stdinReadWatch() + go telemetryLoop() + go diagnosticLoop() + setupTelemetryConfigHandler() + go updateTelemetryCountsLoop() + go backupCleanupLoop() + go startupActivityUpdate(firstLaunch) // must be after startConfigWatcher() + blocklogger.InitBlockLogger() + jobcontroller.InitJobController() + blockcontroller.InitBlockController() + err = wcore.InitBadgeStore() + if err != nil { + log.Printf("error initializing badge store: %v\n", err) + return + } + go func() { + defer func() { + panichandler.PanicHandler("GetSystemSummary", recover()) + }() + wavebase.GetSystemSummary() + }() + + webListener, err := web.MakeTCPListener("web") + if err != nil { + log.Printf("error creating web listener: %v\n", err) + return + } + wsListener, err := web.MakeTCPListener("websocket") + if err != nil { + log.Printf("error creating websocket listener: %v\n", err) + return + } + go web.RunWebSocketServer(wsListener) + unixListener, err := web.MakeUnixListener() + if err != nil { + log.Printf("error creating unix listener: %v\n", err) + return + } + go func() { + if BuildTime == "" { + BuildTime = "0" + } + // use fmt instead of log here to make sure it goes directly to stderr + fmt.Fprintf(os.Stderr, "WAVESRV-ESTART ws:%s web:%s version:%s buildtime:%s\n", wsListener.Addr(), webListener.Addr(), WaveVersion, BuildTime) + }() + go wshutil.RunWshRpcOverListener(unixListener, nil) + web.RunWebServer(webListener) // blocking + runtime.KeepAlive(waveLock) +} diff --git a/cmd/test-conn/cliprovider.go b/cmd/test-conn/cliprovider.go new file mode 100644 index 000000000..e12bbc988 --- /dev/null +++ b/cmd/test-conn/cliprovider.go @@ -0,0 +1,56 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + + "github.com/s-zx/crest/pkg/userinput" +) + +type CLIProvider struct { + AutoAccept bool +} + +func (p *CLIProvider) GetUserInput(ctx context.Context, request *userinput.UserInputRequest) (*userinput.UserInputResponse, error) { + response := &userinput.UserInputResponse{ + Type: request.ResponseType, + RequestId: request.RequestId, + } + + if request.Title != "" { + fmt.Printf("\n=== %s ===\n", request.Title) + } + fmt.Printf("%s\n", request.QueryText) + + if p.AutoAccept { + fmt.Printf("Auto-accepting (use -i for interactive mode)\n") + response.Confirm = true + response.Text = "yes" + return response, nil + } + + reader := bufio.NewReader(os.Stdin) + fmt.Printf("Accept? [y/n]: ") + text, err := reader.ReadString('\n') + if err != nil { + response.ErrorMsg = fmt.Sprintf("error reading input: %v", err) + return response, err + } + + text = strings.TrimSpace(strings.ToLower(text)) + if text == "y" || text == "yes" { + response.Confirm = true + response.Text = "yes" + } else { + response.Confirm = false + response.Text = "no" + } + + return response, nil +} diff --git a/cmd/test-conn/main-test-conn.go b/cmd/test-conn/main-test-conn.go new file mode 100644 index 000000000..799583227 --- /dev/null +++ b/cmd/test-conn/main-test-conn.go @@ -0,0 +1,104 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "flag" + "fmt" + "log" + "os" + "time" +) + +var ( + WaveVersion = "0.0.0" + BuildTime = "0" +) + +func usage() { + fmt.Fprintf(os.Stderr, `Test Harness for SSH Connection Flows + +Usage: + test-conn [flags] [args...] + +Commands: + connect - Test basic SSH connection with wsh + ssh - Test basic SSH connection + exec - Execute command and show output (no wsh) + wshexec - Execute command with wsh enabled + shell - Start interactive shell session + +Flags: + -t duration Connection timeout (default: 60s) + -i Interactive mode (prompt for user input instead of auto-accept) + -v Show version and exit + +Examples: + test-conn ssh user@example.com + test-conn exec user@example.com "ls -la" + test-conn wshexec user@example.com "wsh version" + test-conn -i connect user@example.com + test-conn shell user@example.com + +`) + os.Exit(1) +} + +func main() { + timeoutFlag := flag.Duration("t", 60*time.Second, "connection timeout") + interactiveFlag := flag.Bool("i", false, "interactive mode (prompt for user input)") + versionFlag := flag.Bool("v", false, "show version") + + flag.Usage = usage + flag.Parse() + + if *versionFlag { + fmt.Printf("test-conn version %s (built %s)\n", WaveVersion, BuildTime) + os.Exit(0) + } + + args := flag.Args() + if len(args) < 2 { + usage() + } + + command := args[0] + connName := args[1] + + autoAccept := !*interactiveFlag + + err := initTestHarness(autoAccept) + if err != nil { + log.Fatalf("Failed to initialize: %v", err) + } + + switch command { + case "ssh", "connect": + err = testBasicConnect(connName, *timeoutFlag) + + case "exec": + if len(args) < 3 { + log.Fatalf("exec command requires a command argument") + } + cmd := args[2] + err = testShellWithCommand(connName, cmd, *timeoutFlag) + + case "wshexec": + if len(args) < 3 { + log.Fatalf("wshexec command requires a command argument") + } + cmd := args[2] + err = testWshExec(connName, cmd, *timeoutFlag) + + case "shell": + err = testInteractiveShell(connName, *timeoutFlag) + + default: + log.Fatalf("Unknown command: %s", command) + } + + if err != nil { + log.Fatalf("Error: %v", err) + } +} diff --git a/cmd/test-conn/testutil.go b/cmd/test-conn/testutil.go new file mode 100644 index 000000000..ef9d0a3b3 --- /dev/null +++ b/cmd/test-conn/testutil.go @@ -0,0 +1,326 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "runtime" + "time" + + "github.com/google/uuid" + "github.com/s-zx/crest/pkg/remote" + "github.com/s-zx/crest/pkg/remote/conncontroller" + "github.com/s-zx/crest/pkg/shellexec" + "github.com/s-zx/crest/pkg/userinput" + "github.com/s-zx/crest/pkg/util/shellutil" + "github.com/s-zx/crest/pkg/wavebase" + "github.com/s-zx/crest/pkg/wavejwt" + "github.com/s-zx/crest/pkg/waveobj" + "github.com/s-zx/crest/pkg/wconfig" + "github.com/s-zx/crest/pkg/wshrpc/wshserver" + "github.com/s-zx/crest/pkg/wshutil" + "github.com/s-zx/crest/pkg/wstore" +) + +func setupWaveEnvVars() error { + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get home directory: %w", err) + } + + isDev := os.Getenv("WAVETERM_DEV") != "" + devSuffix := "" + if isDev { + devSuffix = "-dev" + } + + configHome := os.Getenv("WAVETERM_CONFIG_HOME") + if configHome == "" { + configHome = filepath.Join(homeDir, ".config", "crest"+devSuffix) + os.Setenv("WAVETERM_CONFIG_HOME", configHome) + } + log.Printf("Using config directory: %s", configHome) + + dataHome := os.Getenv("WAVETERM_DATA_HOME") + if dataHome == "" { + if runtime.GOOS == "darwin" { + dataHome = filepath.Join(homeDir, "Library", "Application Support", "crest"+devSuffix) + os.Setenv("WAVETERM_DATA_HOME", dataHome) + } else { + return fmt.Errorf("WAVETERM_DATA_HOME must be set on non-macOS systems") + } + } + log.Printf("Using data directory: %s", dataHome) + + return nil +} + +func initTestHarness(autoAccept bool) error { + log.Printf("Initializing test harness...") + + err := setupWaveEnvVars() + if err != nil { + return fmt.Errorf("failed to setup wave env vars: %w", err) + } + + err = wavebase.CacheAndRemoveEnvVars() + if err != nil { + return fmt.Errorf("failed to cache env vars: %w", err) + } + + wshutil.DefaultRouter = wshutil.NewWshRouter() + wshutil.DefaultRouter.SetAsRootRouter() + + wstore.SetClientId("test-client-" + fmt.Sprintf("%d", time.Now().Unix())) + + userinput.SetUserInputProvider(&CLIProvider{AutoAccept: autoAccept}) + + keyPair, err := wavejwt.GenerateKeyPair() + if err != nil { + return fmt.Errorf("failed to generate JWT key pair: %w", err) + } + + err = wavejwt.SetPrivateKey(keyPair.PrivateKey) + if err != nil { + return fmt.Errorf("failed to set JWT private key: %w", err) + } + + err = wavejwt.SetPublicKey(keyPair.PublicKey) + if err != nil { + return fmt.Errorf("failed to set JWT public key: %w", err) + } + + rpc := wshserver.GetMainRpcClient() + wshutil.DefaultRouter.RegisterTrustedLeaf(rpc, wshutil.DefaultRoute) + + wconfig.GetWatcher().Start() + + log.Printf("Test harness initialized") + return nil +} + +func testBasicConnect(connName string, timeout time.Duration) error { + opts, err := remote.ParseOpts(connName) + if err != nil { + return fmt.Errorf("failed to parse connection string: %w", err) + } + + log.Printf("Connecting to %s...", opts.String()) + + conn := conncontroller.GetConn(opts) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + err = conn.Connect(ctx, &wconfig.ConnKeywords{}) + if err != nil { + return fmt.Errorf("connection failed: %w", err) + } + + status := conn.DeriveConnStatus() + log.Printf("✓ Connected!") + log.Printf(" Status: %s", status.Status) + log.Printf(" WshEnabled: %v", status.WshEnabled) + log.Printf(" Connection: %s", status.Connection) + if status.WshVersion != "" { + log.Printf(" WshVersion: %s", status.WshVersion) + } + if status.WshError != "" { + log.Printf(" WshError: %s", status.WshError) + } + if status.NoWshReason != "" { + log.Printf(" NoWshReason: %s", status.NoWshReason) + } + + return nil +} + +func testShellWithCommand(connName string, cmd string, timeout time.Duration) error { + opts, err := remote.ParseOpts(connName) + if err != nil { + return fmt.Errorf("failed to parse connection string: %w", err) + } + + log.Printf("Connecting to %s...", opts.String()) + + conn := conncontroller.GetConn(opts) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + err = conn.Connect(ctx, &wconfig.ConnKeywords{}) + if err != nil { + return fmt.Errorf("connection failed: %w", err) + } + + log.Printf("✓ Connected! Starting shell...") + + termSize := waveobj.TermSize{Rows: 24, Cols: 80} + shellProc, err := shellexec.StartRemoteShellProcNoWsh(ctx, termSize, "", shellexec.CommandOptsType{}, conn) + if err != nil { + return fmt.Errorf("failed to start shell: %w", err) + } + defer shellProc.Close() + + log.Printf("✓ Shell started! Executing: %s", cmd) + + _, err = shellProc.Cmd.Write([]byte(cmd + "\n")) + if err != nil { + return fmt.Errorf("failed to write command: %w", err) + } + + time.Sleep(500 * time.Millisecond) + + buf := make([]byte, 8192) + n, err := shellProc.Cmd.Read(buf) + if err != nil { + log.Printf("Warning: read error (may be expected): %v", err) + } + + if n > 0 { + log.Printf("\n--- Output ---\n%s\n--- End Output ---", string(buf[:n])) + } else { + log.Printf("No output received (timeout or no data)") + } + + return nil +} + +func testWshExec(connName string, cmd string, timeout time.Duration) error { + opts, err := remote.ParseOpts(connName) + if err != nil { + return fmt.Errorf("failed to parse connection string: %w", err) + } + + log.Printf("Connecting to %s with wsh enabled...", opts.String()) + + conn := conncontroller.GetConn(opts) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + wshEnabled := true + err = conn.Connect(ctx, &wconfig.ConnKeywords{ + ConnWshEnabled: &wshEnabled, + }) + if err != nil { + return fmt.Errorf("connection failed: %w", err) + } + + status := conn.DeriveConnStatus() + log.Printf("✓ Connected! (wsh enabled: %v)", status.WshEnabled) + if status.WshVersion != "" { + log.Printf(" wsh version: %s", status.WshVersion) + } + if !status.WshEnabled { + log.Printf(" WARNING: wsh not enabled - reason: %s", status.NoWshReason) + } + + log.Printf("Starting wsh-enabled shell...") + + swapToken := &shellutil.TokenSwapEntry{ + Token: uuid.New().String(), + Env: make(map[string]string), + Exp: time.Now().Add(5 * time.Minute), + } + swapToken.Env["TERM_PROGRAM"] = "crest" + swapToken.Env["WAVETERM"] = "1" + swapToken.Env["WAVETERM_VERSION"] = wavebase.WaveVersion + swapToken.Env["WAVETERM_CONN"] = connName + + cmdOpts := shellexec.CommandOptsType{ + SwapToken: swapToken, + } + + termSize := waveobj.TermSize{Rows: 24, Cols: 80} + shellProc, err := shellexec.StartRemoteShellProc(ctx, ctx, termSize, "", cmdOpts, conn) + if err != nil { + return fmt.Errorf("failed to start shell: %w", err) + } + defer shellProc.Close() + + log.Printf("✓ Shell started! Executing: %s", cmd) + + _, err = shellProc.Cmd.Write([]byte(cmd + "\n")) + if err != nil { + return fmt.Errorf("failed to write command: %w", err) + } + + time.Sleep(500 * time.Millisecond) + + buf := make([]byte, 8192) + n, err := shellProc.Cmd.Read(buf) + if err != nil { + log.Printf("Warning: read error (may be expected): %v", err) + } + + if n > 0 { + log.Printf("\n--- Output ---\n%s\n--- End Output ---", string(buf[:n])) + } else { + log.Printf("No output received (timeout or no data)") + } + + return nil +} + +func testInteractiveShell(connName string, timeout time.Duration) error { + opts, err := remote.ParseOpts(connName) + if err != nil { + return fmt.Errorf("failed to parse connection string: %w", err) + } + + log.Printf("Connecting to %s...", opts.String()) + + conn := conncontroller.GetConn(opts) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + err = conn.Connect(ctx, &wconfig.ConnKeywords{}) + if err != nil { + return fmt.Errorf("connection failed: %w", err) + } + + log.Printf("✓ Connected! Starting interactive shell...") + log.Printf("Note: This is a simple test - output may be mixed with prompts") + log.Printf("Type commands and press Enter. Type 'exit' to quit.\n") + + termSize := waveobj.TermSize{Rows: 24, Cols: 80} + shellProc, err := shellexec.StartRemoteShellProcNoWsh(ctx, termSize, "", shellexec.CommandOptsType{}, conn) + if err != nil { + return fmt.Errorf("failed to start shell: %w", err) + } + defer shellProc.Close() + + go func() { + buf := make([]byte, 8192) + for { + n, err := shellProc.Cmd.Read(buf) + if err != nil { + return + } + if n > 0 { + fmt.Print(string(buf[:n])) + } + } + }() + + go func() { + buf := make([]byte, 1) + for { + n, err := os.Stdin.Read(buf) + if err != nil { + return + } + if n > 0 { + shellProc.Cmd.Write(buf[:n]) + } + } + }() + + shellProc.Wait() + log.Printf("\nShell exited") + + return nil +} diff --git a/cmd/test-streammanager/bridge.go b/cmd/test-streammanager/bridge.go new file mode 100644 index 000000000..3528d9944 --- /dev/null +++ b/cmd/test-streammanager/bridge.go @@ -0,0 +1,40 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + + "github.com/s-zx/crest/pkg/wshrpc" +) + +// WriterBridge - used by the writer broker +// Sends data to the pipe, receives acks from the pipe +type WriterBridge struct { + pipe *DeliveryPipe +} + +func (b *WriterBridge) StreamDataCommand(data wshrpc.CommandStreamData, opts *wshrpc.RpcOpts) error { + b.pipe.EnqueueData(data) + return nil +} + +func (b *WriterBridge) StreamDataAckCommand(ack wshrpc.CommandStreamAckData, opts *wshrpc.RpcOpts) error { + return fmt.Errorf("writer bridge should not send acks") +} + +// ReaderBridge - used by the reader broker +// Sends acks to the pipe, receives data from the pipe +type ReaderBridge struct { + pipe *DeliveryPipe +} + +func (b *ReaderBridge) StreamDataCommand(data wshrpc.CommandStreamData, opts *wshrpc.RpcOpts) error { + return fmt.Errorf("reader bridge should not send data") +} + +func (b *ReaderBridge) StreamDataAckCommand(ack wshrpc.CommandStreamAckData, opts *wshrpc.RpcOpts) error { + b.pipe.EnqueueAck(ack) + return nil +} diff --git a/cmd/test-streammanager/deliverypipe.go b/cmd/test-streammanager/deliverypipe.go new file mode 100644 index 000000000..f249d98d1 --- /dev/null +++ b/cmd/test-streammanager/deliverypipe.go @@ -0,0 +1,249 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/base64" + "math/rand" + "sort" + "sync" + "time" + + "github.com/s-zx/crest/pkg/wshrpc" +) + +type DeliveryConfig struct { + Delay time.Duration + Skew time.Duration +} + +type taggedPacket struct { + seq uint64 + deliveryTime time.Time + isData bool + dataPk wshrpc.CommandStreamData + ackPk wshrpc.CommandStreamAckData + dataSize int +} + +type DeliveryPipe struct { + lock sync.Mutex + config DeliveryConfig + + // Sequence counters (separate for data and ack) + dataSeq uint64 + ackSeq uint64 + + // Pending packets sorted by (deliveryTime, seq) + dataPending []taggedPacket + ackPending []taggedPacket + + // Delivery targets + dataTarget func(wshrpc.CommandStreamData) + ackTarget func(wshrpc.CommandStreamAckData) + + // Control + closed bool + wg sync.WaitGroup + + // Metrics + metrics *Metrics + lastDataSeqNum int64 + lastAckSeqNum int64 + + // Byte tracking for high water mark + currentBytes int64 +} + +func NewDeliveryPipe(config DeliveryConfig, metrics *Metrics) *DeliveryPipe { + return &DeliveryPipe{ + config: config, + metrics: metrics, + lastDataSeqNum: -1, + lastAckSeqNum: -1, + } +} + +func (dp *DeliveryPipe) SetDataTarget(fn func(wshrpc.CommandStreamData)) { + dp.lock.Lock() + defer dp.lock.Unlock() + dp.dataTarget = fn +} + +func (dp *DeliveryPipe) SetAckTarget(fn func(wshrpc.CommandStreamAckData)) { + dp.lock.Lock() + defer dp.lock.Unlock() + dp.ackTarget = fn +} + +func (dp *DeliveryPipe) EnqueueData(pkt wshrpc.CommandStreamData) { + dp.lock.Lock() + defer dp.lock.Unlock() + + if dp.closed { + return + } + + dataSize := base64.StdEncoding.DecodedLen(len(pkt.Data64)) + dp.dataSeq++ + tagged := taggedPacket{ + seq: dp.dataSeq, + deliveryTime: dp.computeDeliveryTime(), + isData: true, + dataPk: pkt, + dataSize: dataSize, + } + + dp.dataPending = append(dp.dataPending, tagged) + dp.sortPending(&dp.dataPending) + + dp.currentBytes += int64(dataSize) + if dp.metrics != nil { + dp.metrics.AddDataPacket() + dp.metrics.UpdatePipeHighWaterMark(dp.currentBytes) + } +} + +func (dp *DeliveryPipe) EnqueueAck(pkt wshrpc.CommandStreamAckData) { + dp.lock.Lock() + defer dp.lock.Unlock() + + if dp.closed { + return + } + + dp.ackSeq++ + tagged := taggedPacket{ + seq: dp.ackSeq, + deliveryTime: dp.computeDeliveryTime(), + isData: false, + ackPk: pkt, + } + + dp.ackPending = append(dp.ackPending, tagged) + dp.sortPending(&dp.ackPending) + + if dp.metrics != nil { + dp.metrics.AddAckPacket() + } +} + +func (dp *DeliveryPipe) computeDeliveryTime() time.Time { + base := time.Now().Add(dp.config.Delay) + + if dp.config.Skew == 0 { + return base + } + + // Random skew: -skew to +skew + skewNs := dp.config.Skew.Nanoseconds() + randomSkew := time.Duration(rand.Int63n(2*skewNs+1) - skewNs) + return base.Add(randomSkew) +} + +func (dp *DeliveryPipe) sortPending(pending *[]taggedPacket) { + sort.Slice(*pending, func(i, j int) bool { + pi, pj := (*pending)[i], (*pending)[j] + if pi.deliveryTime.Equal(pj.deliveryTime) { + return pi.seq < pj.seq + } + return pi.deliveryTime.Before(pj.deliveryTime) + }) +} + +func (dp *DeliveryPipe) Start() { + dp.wg.Add(2) + go dp.dataDeliveryLoop() + go dp.ackDeliveryLoop() +} + +func (dp *DeliveryPipe) dataDeliveryLoop() { + defer dp.wg.Done() + dp.deliveryLoop( + func() *[]taggedPacket { return &dp.dataPending }, + func(pkt taggedPacket) { + if dp.dataTarget != nil { + // Track out-of-order packets + if dp.metrics != nil && dp.lastDataSeqNum != -1 { + if pkt.dataPk.Seq < dp.lastDataSeqNum { + dp.metrics.AddOOOPacket() + } + } + dp.lastDataSeqNum = pkt.dataPk.Seq + dp.dataTarget(pkt.dataPk) + + dp.lock.Lock() + dp.currentBytes -= int64(pkt.dataSize) + dp.lock.Unlock() + } + }, + ) +} + +func (dp *DeliveryPipe) ackDeliveryLoop() { + defer dp.wg.Done() + dp.deliveryLoop( + func() *[]taggedPacket { return &dp.ackPending }, + func(pkt taggedPacket) { + if dp.ackTarget != nil { + // Track out-of-order acks + if dp.metrics != nil && dp.lastAckSeqNum != -1 { + if pkt.ackPk.Seq < dp.lastAckSeqNum { + dp.metrics.AddOOOPacket() + } + } + dp.lastAckSeqNum = pkt.ackPk.Seq + dp.ackTarget(pkt.ackPk) + } + }, + ) +} + +func (dp *DeliveryPipe) deliveryLoop( + getPending func() *[]taggedPacket, + deliver func(taggedPacket), +) { + for { + dp.lock.Lock() + if dp.closed { + dp.lock.Unlock() + return + } + + pending := getPending() + now := time.Now() + + // Find all packets ready for delivery (deliveryTime <= now) + readyCount := 0 + for _, pkt := range *pending { + if pkt.deliveryTime.After(now) { + break + } + readyCount++ + } + + // Extract ready packets + ready := make([]taggedPacket, readyCount) + copy(ready, (*pending)[:readyCount]) + *pending = (*pending)[readyCount:] + + dp.lock.Unlock() + + // Deliver all ready packets (outside lock) + for _, pkt := range ready { + deliver(pkt) + } + + // Always sleep 1ms - simple busy loop + time.Sleep(1 * time.Millisecond) + } +} + +func (dp *DeliveryPipe) Close() { + dp.lock.Lock() + dp.closed = true + dp.lock.Unlock() + + dp.wg.Wait() +} diff --git a/cmd/test-streammanager/generator.go b/cmd/test-streammanager/generator.go new file mode 100644 index 000000000..5cfc92b4b --- /dev/null +++ b/cmd/test-streammanager/generator.go @@ -0,0 +1,40 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "io" +) + +// Base64 charset: all printable, easy to inspect manually +const Base64Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + +type TestDataGenerator struct { + totalBytes int64 + generated int64 +} + +func NewTestDataGenerator(totalBytes int64) *TestDataGenerator { + return &TestDataGenerator{totalBytes: totalBytes} +} + +func (g *TestDataGenerator) Read(p []byte) (n int, err error) { + if g.generated >= g.totalBytes { + return 0, io.EOF + } + + remaining := g.totalBytes - g.generated + toRead := int64(len(p)) + if toRead > remaining { + toRead = remaining + } + + // Sequential pattern using base64 chars (0-63 cycling) + for i := int64(0); i < toRead; i++ { + p[i] = Base64Chars[(g.generated+i)%64] + } + + g.generated += toRead + return int(toRead), nil +} diff --git a/cmd/test-streammanager/main-test-streammanager.go b/cmd/test-streammanager/main-test-streammanager.go new file mode 100644 index 000000000..d65083f5e --- /dev/null +++ b/cmd/test-streammanager/main-test-streammanager.go @@ -0,0 +1,254 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "io" + "log" + "os" + "time" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/jobmanager" + "github.com/s-zx/crest/pkg/streamclient" + "github.com/s-zx/crest/pkg/wshrpc" +) + +type TestConfig struct { + Mode string + DataSize int64 + Delay time.Duration + Skew time.Duration + WindowSize int + SlowReader int + Verbose bool +} + +var config TestConfig + +var rootCmd = &cobra.Command{ + Use: "test-streammanager", + Short: "Integration test for StreamManager streaming system", + RunE: func(cmd *cobra.Command, args []string) error { + return runTest(config) + }, +} + +func init() { + rootCmd.Flags().StringVar(&config.Mode, "mode", "streammanager", "Writer mode: 'streammanager' or 'writer'") + rootCmd.Flags().Int64Var(&config.DataSize, "size", 10*1024*1024, "Total data to transfer (bytes)") + rootCmd.Flags().DurationVar(&config.Delay, "delay", 0, "Base delivery delay (e.g., 10ms)") + rootCmd.Flags().DurationVar(&config.Skew, "skew", 0, "Delivery skew +/- (e.g., 5ms)") + rootCmd.Flags().IntVar(&config.WindowSize, "windowsize", 64*1024, "Window size for both sender and receiver") + rootCmd.Flags().IntVar(&config.SlowReader, "slowreader", 0, "Slow reader mode: bytes per second (0=disabled, e.g., 1024)") + rootCmd.Flags().BoolVar(&config.Verbose, "verbose", false, "Enable verbose logging") +} + +func main() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +func runTest(config TestConfig) error { + if config.Mode != "streammanager" && config.Mode != "writer" { + return fmt.Errorf("invalid mode: %s (must be 'streammanager' or 'writer')", config.Mode) + } + + fmt.Printf("Starting Streaming Integration Test\n") + fmt.Printf(" Mode: %s\n", config.Mode) + fmt.Printf(" Data Size: %d bytes\n", config.DataSize) + fmt.Printf(" Delay: %v, Skew: %v\n", config.Delay, config.Skew) + fmt.Printf(" Window Size: %d\n", config.WindowSize) + if config.SlowReader > 0 { + fmt.Printf(" Slow Reader: %d bytes/sec\n", config.SlowReader) + } + + // 1. Create metrics + metrics := NewMetrics() + + // 2. Create the delivery pipe + pipe := NewDeliveryPipe(DeliveryConfig{ + Delay: config.Delay, + Skew: config.Skew, + }, metrics) + + // 3. Create brokers with bridges + writerBridge := &WriterBridge{pipe: pipe} + readerBridge := &ReaderBridge{pipe: pipe} + + writerBroker := streamclient.NewBroker(writerBridge) + readerBroker := streamclient.NewBroker(readerBridge) + + // 4. Wire up delivery targets + pipe.SetDataTarget(readerBroker.RecvData) + pipe.SetAckTarget(writerBroker.RecvAck) + + // 5. Start the delivery pipe + pipe.Start() + + // 6. Create the reader side + reader, streamMeta := readerBroker.CreateStreamReader("reader-route", "writer-route", int64(config.WindowSize)) + + // 7. Set up writer side based on mode + var writerDone chan error + if config.Mode == "streammanager" { + writerDone = runStreamManagerMode(config, writerBroker, streamMeta) + } else { + writerDone = runWriterMode(config, writerBroker, streamMeta) + } + + // 8. Create verifier + verifier := NewVerifier(config.DataSize) + + // 9. Create metrics writer wrapper + metricsWriter := &MetricsWriter{ + writer: verifier, + metrics: metrics, + } + + // 10. Wrap reader with slow reader if configured + var actualReader io.Reader = reader + if config.SlowReader > 0 { + actualReader = NewSlowReader(reader, config.SlowReader) + } + + // 11. Start reading from stream reader and writing to verifier + metrics.Start() + + readerDone := make(chan error) + go func() { + _, err := io.Copy(metricsWriter, actualReader) + readerDone <- err + }() + + // 12. Wait for completion + var writerErr, readerErr error + if writerDone != nil { + writerErr = <-writerDone + } + readerErr = <-readerDone + metrics.End() + + // 13. Cleanup + pipe.Close() + writerBroker.Close() + readerBroker.Close() + + // 14. Report results + fmt.Println(metrics.Report()) + fmt.Printf("Verification: received=%d, mismatches=%d\n", + verifier.TotalReceived(), verifier.Mismatches()) + + if writerErr != nil && writerErr != io.EOF { + return fmt.Errorf("writer error: %w", writerErr) + } + + if readerErr != nil && readerErr != io.EOF { + return fmt.Errorf("reader error: %w", readerErr) + } + + if verifier.Mismatches() > 0 { + return fmt.Errorf("data corruption: %d mismatches, first at byte %d", + verifier.Mismatches(), verifier.FirstMismatch()) + } + + fmt.Println("TEST PASSED") + return nil +} + +func runStreamManagerMode(config TestConfig, writerBroker *streamclient.Broker, streamMeta *wshrpc.StreamMeta) chan error { + streamManager := jobmanager.MakeStreamManagerWithSizes(config.WindowSize, 2*1024*1024) + writerBroker.AttachStreamWriter(streamMeta, streamManager) + + dataSender := &BrokerDataSender{broker: writerBroker} + startSeq, err := streamManager.ClientConnected(streamMeta.Id, dataSender, config.WindowSize, 0) + if err != nil { + fmt.Printf("failed to connect stream manager: %v\n", err) + return nil + } + fmt.Printf(" Stream connected, startSeq: %d\n", startSeq) + + generator := NewTestDataGenerator(config.DataSize) + if err := streamManager.AttachReader(generator); err != nil { + fmt.Printf("failed to attach reader: %v\n", err) + return nil + } + + return nil +} + +func runWriterMode(config TestConfig, writerBroker *streamclient.Broker, streamMeta *wshrpc.StreamMeta) chan error { + writer, err := writerBroker.CreateStreamWriter(streamMeta) + if err != nil { + fmt.Printf("failed to create stream writer: %v\n", err) + return nil + } + fmt.Printf(" Stream writer created\n") + + generator := NewTestDataGenerator(config.DataSize) + + done := make(chan error, 1) + go func() { + _, copyErr := io.Copy(writer, generator) + closeErr := writer.Close() + if copyErr != nil && copyErr != io.EOF { + done <- copyErr + } else { + done <- closeErr + } + }() + + return done +} + +// BrokerDataSender implements DataSender interface +type BrokerDataSender struct { + broker *streamclient.Broker +} + +func (s *BrokerDataSender) SendData(dataPk wshrpc.CommandStreamData) { + s.broker.SendData(dataPk) +} + +// MetricsWriter wraps an io.Writer and records bytes written to metrics +type MetricsWriter struct { + writer io.Writer + metrics *Metrics +} + +func (mw *MetricsWriter) Write(p []byte) (n int, err error) { + n, err = mw.writer.Write(p) + if n > 0 { + mw.metrics.AddBytes(int64(n)) + } + return n, err +} + +// SlowReader wraps an io.Reader and rate-limits reads to a specified bytes/sec +type SlowReader struct { + reader io.Reader + bytesPerSec int +} + +func NewSlowReader(reader io.Reader, bytesPerSec int) *SlowReader { + return &SlowReader{ + reader: reader, + bytesPerSec: bytesPerSec, + } +} + +func (sr *SlowReader) Read(p []byte) (n int, err error) { + time.Sleep(1 * time.Second) + + readSize := sr.bytesPerSec + if readSize > len(p) { + readSize = len(p) + } + + n, err = sr.reader.Read(p[:readSize]) + log.Printf("SlowReader: read %d bytes, err=%v", n, err) + return n, err +} diff --git a/cmd/test-streammanager/metrics.go b/cmd/test-streammanager/metrics.go new file mode 100644 index 000000000..94b4f4169 --- /dev/null +++ b/cmd/test-streammanager/metrics.go @@ -0,0 +1,110 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "sync" + "time" +) + +type Metrics struct { + lock sync.Mutex + + // Timing + startTime time.Time + endTime time.Time + + // Data transfer + totalBytes int64 + + // Packet counts + dataPackets int64 + ackPackets int64 + + // Out of order tracking + oooPackets int64 + + // High water mark for pipe bytes + pipeHighWaterMark int64 +} + +func NewMetrics() *Metrics { + return &Metrics{} +} + +func (m *Metrics) Start() { + m.lock.Lock() + defer m.lock.Unlock() + m.startTime = time.Now() +} + +func (m *Metrics) End() { + m.lock.Lock() + defer m.lock.Unlock() + m.endTime = time.Now() +} + +func (m *Metrics) AddDataPacket() { + m.lock.Lock() + defer m.lock.Unlock() + m.dataPackets++ +} + +func (m *Metrics) AddAckPacket() { + m.lock.Lock() + defer m.lock.Unlock() + m.ackPackets++ +} + +func (m *Metrics) AddOOOPacket() { + m.lock.Lock() + defer m.lock.Unlock() + m.oooPackets++ +} + +func (m *Metrics) AddBytes(n int64) { + m.lock.Lock() + defer m.lock.Unlock() + m.totalBytes += n +} + +func (m *Metrics) UpdatePipeHighWaterMark(currentBytes int64) { + m.lock.Lock() + defer m.lock.Unlock() + if currentBytes > m.pipeHighWaterMark { + m.pipeHighWaterMark = currentBytes + } +} + +func (m *Metrics) GetPipeHighWaterMark() int64 { + m.lock.Lock() + defer m.lock.Unlock() + return m.pipeHighWaterMark +} + +func (m *Metrics) Report() string { + m.lock.Lock() + defer m.lock.Unlock() + + duration := m.endTime.Sub(m.startTime) + durationSecs := duration.Seconds() + if durationSecs == 0 { + durationSecs = 1.0 + } + throughput := float64(m.totalBytes) / durationSecs / 1024 / 1024 + + return fmt.Sprintf(` +StreamManager Integration Test Results +====================================== +Duration: %v +Total Bytes: %d +Throughput: %.2f MB/s +Data Packets: %d +Ack Packets: %d +OOO Packets: %d +Pipe High Water: %d bytes (%.2f KB) +`, duration, m.totalBytes, throughput, m.dataPackets, m.ackPackets, m.oooPackets, + m.pipeHighWaterMark, float64(m.pipeHighWaterMark)/1024) +} diff --git a/cmd/test-streammanager/verifier.go b/cmd/test-streammanager/verifier.go new file mode 100644 index 000000000..e6abe518a --- /dev/null +++ b/cmd/test-streammanager/verifier.go @@ -0,0 +1,63 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "sync" +) + +type Verifier struct { + lock sync.Mutex + expectedGen *TestDataGenerator + totalReceived int64 + mismatches int + firstMismatch int64 +} + +func NewVerifier(totalBytes int64) *Verifier { + return &Verifier{ + expectedGen: NewTestDataGenerator(totalBytes), + firstMismatch: -1, + } +} + +func (v *Verifier) Write(p []byte) (n int, err error) { + v.lock.Lock() + defer v.lock.Unlock() + + expected := make([]byte, len(p)) + // expectedGen.Read() error ignored: TestDataGenerator is deterministic and won't fail, + // and any data length mismatch will be caught by byte comparison below + v.expectedGen.Read(expected) + + for i := 0; i < len(p); i++ { + if p[i] != expected[i] { + v.mismatches++ + if v.firstMismatch == -1 { + v.firstMismatch = v.totalReceived + int64(i) + } + } + } + + v.totalReceived += int64(len(p)) + return len(p), nil +} + +func (v *Verifier) TotalReceived() int64 { + v.lock.Lock() + defer v.lock.Unlock() + return v.totalReceived +} + +func (v *Verifier) Mismatches() int { + v.lock.Lock() + defer v.lock.Unlock() + return v.mismatches +} + +func (v *Verifier) FirstMismatch() int64 { + v.lock.Lock() + defer v.lock.Unlock() + return v.firstMismatch +} diff --git a/cmd/test/test-main.go b/cmd/test/test-main.go new file mode 100644 index 000000000..bb16343da --- /dev/null +++ b/cmd/test/test-main.go @@ -0,0 +1,8 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +func main() { + +} diff --git a/cmd/wsh/cmd/csscolormap.go b/cmd/wsh/cmd/csscolormap.go new file mode 100644 index 000000000..51addf547 --- /dev/null +++ b/cmd/wsh/cmd/csscolormap.go @@ -0,0 +1,147 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +var CssColorNames = map[string]bool{ + "aliceblue": true, + "antiquewhite": true, + "aqua": true, + "aquamarine": true, + "azure": true, + "beige": true, + "bisque": true, + "black": true, + "blanchedalmond": true, + "blue": true, + "blueviolet": true, + "brown": true, + "burlywood": true, + "cadetblue": true, + "chartreuse": true, + "chocolate": true, + "coral": true, + "cornflowerblue": true, + "cornsilk": true, + "crimson": true, + "cyan": true, + "darkblue": true, + "darkcyan": true, + "darkgoldenrod": true, + "darkgray": true, + "darkgreen": true, + "darkkhaki": true, + "darkmagenta": true, + "darkolivegreen": true, + "darkorange": true, + "darkorchid": true, + "darkred": true, + "darksalmon": true, + "darkseagreen": true, + "darkslateblue": true, + "darkslategray": true, + "darkturquoise": true, + "darkviolet": true, + "deeppink": true, + "deepskyblue": true, + "dimgray": true, + "dodgerblue": true, + "firebrick": true, + "floralwhite": true, + "forestgreen": true, + "fuchsia": true, + "gainsboro": true, + "ghostwhite": true, + "gold": true, + "goldenrod": true, + "gray": true, + "green": true, + "greenyellow": true, + "honeydew": true, + "hotpink": true, + "indianred": true, + "indigo": true, + "ivory": true, + "khaki": true, + "lavender": true, + "lavenderblush": true, + "lawngreen": true, + "lemonchiffon": true, + "lightblue": true, + "lightcoral": true, + "lightcyan": true, + "lightgoldenrodyellow": true, + "lightgray": true, + "lightgreen": true, + "lightpink": true, + "lightsalmon": true, + "lightseagreen": true, + "lightskyblue": true, + "lightslategray": true, + "lightsteelblue": true, + "lightyellow": true, + "lime": true, + "limegreen": true, + "linen": true, + "magenta": true, + "maroon": true, + "mediumaquamarine": true, + "mediumblue": true, + "mediumorchid": true, + "mediumpurple": true, + "mediumseagreen": true, + "mediumslateblue": true, + "mediumspringgreen": true, + "mediumturquoise": true, + "mediumvioletred": true, + "midnightblue": true, + "mintcream": true, + "mistyrose": true, + "moccasin": true, + "navajowhite": true, + "navy": true, + "oldlace": true, + "olive": true, + "olivedrab": true, + "orange": true, + "orangered": true, + "orchid": true, + "palegoldenrod": true, + "palegreen": true, + "paleturquoise": true, + "palevioletred": true, + "papayawhip": true, + "peachpuff": true, + "peru": true, + "pink": true, + "plum": true, + "powderblue": true, + "purple": true, + "red": true, + "rosybrown": true, + "royalblue": true, + "saddlebrown": true, + "salmon": true, + "sandybrown": true, + "seagreen": true, + "seashell": true, + "sienna": true, + "silver": true, + "skyblue": true, + "slateblue": true, + "slategray": true, + "snow": true, + "springgreen": true, + "steelblue": true, + "tan": true, + "teal": true, + "thistle": true, + "tomato": true, + "turquoise": true, + "violet": true, + "wheat": true, + "white": true, + "whitesmoke": true, + "yellow": true, + "yellowgreen": true, +} diff --git a/cmd/wsh/cmd/setmeta_test.go b/cmd/wsh/cmd/setmeta_test.go new file mode 100644 index 000000000..6f0e7be6b --- /dev/null +++ b/cmd/wsh/cmd/setmeta_test.go @@ -0,0 +1,170 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "reflect" + "testing" +) + +func TestParseMetaSets(t *testing.T) { + tests := []struct { + name string + input []string + want map[string]any + wantErr bool + }{ + { + name: "basic types", + input: []string{"str=hello", "num=42", "float=3.14", "bool=true", "null=null"}, + want: map[string]any{ + "str": "hello", + "num": int64(42), + "float": float64(3.14), + "bool": true, + "null": nil, + }, + }, + { + name: "json values", + input: []string{ + `arr=[1,2,3]`, + `obj={"foo":"bar"}`, + `str="quoted"`, + }, + want: map[string]any{ + "arr": []any{float64(1), float64(2), float64(3)}, + "obj": map[string]any{"foo": "bar"}, + "str": "quoted", + }, + }, + { + name: "nested paths", + input: []string{ + "a/b=55", + "a/c=2", + }, + want: map[string]any{ + "a": map[string]any{ + "b": int64(55), + "c": int64(2), + }, + }, + }, + { + name: "deep nesting", + input: []string{ + "a/b/c/d=hello", + }, + want: map[string]any{ + "a": map[string]any{ + "b": map[string]any{ + "c": map[string]any{ + "d": "hello", + }, + }, + }, + }, + }, + { + name: "override nested value", + input: []string{ + "a/b/c=1", + "a/b=2", + }, + want: map[string]any{ + "a": map[string]any{ + "b": int64(2), + }, + }, + }, + { + name: "override with null", + input: []string{ + "a/b=1", + "a/c=2", + "a=null", + }, + want: map[string]any{ + "a": nil, + }, + }, + { + name: "mixed types in path", + input: []string{ + "a/b=1", + "a/c=[1,2,3]", + "a/d/e=true", + }, + want: map[string]any{ + "a": map[string]any{ + "b": int64(1), + "c": []any{float64(1), float64(2), float64(3)}, + "d": map[string]any{ + "e": true, + }, + }, + }, + }, + { + name: "invalid format", + input: []string{"invalid"}, + wantErr: true, + }, + { + name: "invalid json", + input: []string{`a={"invalid`}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseMetaSets(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("parseMetaSets() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { + t.Errorf("parseMetaSets() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestParseMetaValue(t *testing.T) { + tests := []struct { + name string + input string + want any + wantErr bool + }{ + {"empty string", "", nil, false}, + {"null", "null", nil, false}, + {"true", "true", true, false}, + {"false", "false", false, false}, + {"integer", "42", int64(42), false}, + {"negative integer", "-42", int64(-42), false}, + {"hex integer", "0xff", int64(255), false}, + {"float", "3.14", float64(3.14), false}, + {"string", "hello", "hello", false}, + {"json array", "[1,2,3]", []any{float64(1), float64(2), float64(3)}, false}, + {"json object", `{"foo":"bar"}`, map[string]any{"foo": "bar"}, false}, + {"quoted string", `"quoted"`, "quoted", false}, + {"invalid json", `{"invalid`, nil, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseMetaValue(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("parseMetaValue() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { + t.Errorf("parseMetaValue() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/cmd/wsh/cmd/wshcmd-badge.go b/cmd/wsh/cmd/wshcmd-badge.go new file mode 100644 index 000000000..aee5362d7 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-badge.go @@ -0,0 +1,129 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "runtime" + + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/baseds" + "github.com/s-zx/crest/pkg/waveobj" + "github.com/s-zx/crest/pkg/wps" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" + "github.com/s-zx/crest/pkg/wshutil" +) + +var badgeCmd = &cobra.Command{ + Use: "badge [icon]", + Short: "set or clear a block badge", + Args: cobra.MaximumNArgs(1), + RunE: badgeRun, + PreRunE: preRunSetupRpcClient, +} + +var ( + badgeColor string + badgePriority float64 + badgeClear bool + badgeBeep bool + badgePid int +) + +func init() { + rootCmd.AddCommand(badgeCmd) + badgeCmd.Flags().StringVar(&badgeColor, "color", "", "badge color") + badgeCmd.Flags().Float64Var(&badgePriority, "priority", 10, "badge priority") + badgeCmd.Flags().BoolVar(&badgeClear, "clear", false, "clear the badge") + badgeCmd.Flags().BoolVar(&badgeBeep, "beep", false, "play system bell sound") + badgeCmd.Flags().IntVar(&badgePid, "pid", 0, "watch a pid and automatically clear the badge when it exits (default priority 5)") +} + +func badgeRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("badge", rtnErr == nil) + }() + + if badgePid > 0 && runtime.GOOS == "windows" { + return fmt.Errorf("--pid flag is not supported on Windows") + } + if badgePid > 0 && !cmd.Flags().Changed("priority") { + badgePriority = 5 + } + + oref, err := resolveBlockArg() + if err != nil { + return fmt.Errorf("resolving block: %v", err) + } + if oref.OType != waveobj.OType_Block && oref.OType != waveobj.OType_Tab { + return fmt.Errorf("badge oref must be a block or tab (got %q)", oref.OType) + } + + var eventData baseds.BadgeEvent + eventData.ORef = oref.String() + + if badgeClear { + eventData.Clear = true + } else { + icon := "circle-small" + if len(args) > 0 { + icon = args[0] + } + badgeId, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("generating badge id: %v", err) + } + eventData.Badge = &baseds.Badge{ + BadgeId: badgeId.String(), + Icon: icon, + Color: badgeColor, + Priority: badgePriority, + PidLinked: badgePid > 0, + } + } + + event := wps.WaveEvent{ + Event: wps.Event_Badge, + Scopes: []string{oref.String()}, + Data: eventData, + } + + err = wshclient.EventPublishCommand(RpcClient, event, &wshrpc.RpcOpts{NoResponse: true}) + if err != nil { + return fmt.Errorf("publishing badge event: %v", err) + } + + if badgeBeep { + err = wshclient.ElectronSystemBellCommand(RpcClient, &wshrpc.RpcOpts{Route: "electron"}) + if err != nil { + return fmt.Errorf("playing system bell: %v", err) + } + } + + if badgePid > 0 && eventData.Badge != nil { + conn := RpcContext.Conn + if conn == "" { + conn = wshrpc.LocalConnName + } + connRoute := wshutil.MakeConnectionRouteId(conn) + watchData := wshrpc.CommandBadgeWatchPidData{ + Pid: badgePid, + ORef: *oref, + BadgeId: eventData.Badge.BadgeId, + } + err = wshclient.BadgeWatchPidCommand(RpcClient, watchData, &wshrpc.RpcOpts{Route: connRoute}) + if err != nil { + return fmt.Errorf("watching pid: %v", err) + } + } + + if badgeClear { + fmt.Printf("badge cleared\n") + } else { + fmt.Printf("badge set\n") + } + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-blocks.go b/cmd/wsh/cmd/wshcmd-blocks.go new file mode 100644 index 000000000..f6079133d --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-blocks.go @@ -0,0 +1,291 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/waveobj" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +// Command-line flags for the blocks commands +var ( + blocksWindowId string // Window ID to filter blocks by + blocksWorkspaceId string // Workspace ID to filter blocks by + blocksTabId string // Tab ID to filter blocks by + blocksView string // View type to filter blocks by (term, web, etc.) + blocksJSON bool // Whether to output as JSON + blocksTimeout int // Timeout in milliseconds for RPC calls +) + +// BlockDetails represents the information about a block returned by the list command +type BlockDetails struct { + BlockId string `json:"blockid"` // Unique identifier for the block + WorkspaceId string `json:"workspaceid"` // ID of the workspace containing the block + TabId string `json:"tabid"` // ID of the tab containing the block + View string `json:"view"` // Canonical view type (term, web, preview, edit, sysinfo) + Meta waveobj.MetaMapType `json:"meta"` // Block metadata including view type +} + +// blocksListCmd represents the 'blocks list' command +var blocksListCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls", "get"}, + Short: "List blocks in workspaces/windows", + Long: `List blocks with optional filtering by workspace, window, tab, or view type. + +Examples: + # List blocks from all workspaces + wsh blocks list + + # List only terminal blocks + wsh blocks list --view=term + + # Filter by window ID (get IDs from 'wsh workspace list') + wsh blocks list --window=dbca23b5-f89b-4780-a0fe-452f5bc7d900 + + # Filter by workspace ID + wsh blocks list --workspace=12d0c067-378e-454c-872e-77a314248114 + + # Filter by tab ID + wsh blocks list --tab=a0459921-cc1a-48cc-ae7b-5f4821e1c9e1 + + # Output as JSON for scripting + wsh blocks list --json + + # Set a different timeout (in milliseconds) + wsh blocks list --timeout=10000`, + RunE: blocksListRun, + PreRunE: preRunSetupRpcClient, + SilenceUsage: true, +} + +// init registers the blocks commands with the root command +// It configures all the flags and command options +func init() { + blocksListCmd.Flags().StringVar(&blocksWindowId, "window", "", "restrict to window id") + blocksListCmd.Flags().StringVar(&blocksWorkspaceId, "workspace", "", "restrict to workspace id") + blocksListCmd.Flags().StringVar(&blocksTabId, "tab", "", "restrict to specific tab id") + blocksListCmd.Flags().StringVar(&blocksView, "view", "", "restrict to view type (term/terminal, web/browser, preview/edit, sysinfo)") + blocksListCmd.Flags().BoolVar(&blocksJSON, "json", false, "output as JSON") + blocksListCmd.Flags().IntVar(&blocksTimeout, "timeout", 5000, "timeout in milliseconds for RPC calls (default: 5000)") + + for _, cmd := range rootCmd.Commands() { + if cmd.Use == "blocks" { + cmd.AddCommand(blocksListCmd) + return + } + } + + blocksCmd := &cobra.Command{ + Use: "blocks", + Short: "Manage blocks", + Long: "Commands for working with blocks", + } + + blocksCmd.AddCommand(blocksListCmd) + rootCmd.AddCommand(blocksCmd) +} + +// blocksListRun implements the 'blocks list' command +// It retrieves and displays blocks with optional filtering by workspace, window, tab, or view type +func blocksListRun(cmd *cobra.Command, args []string) error { + if v := strings.TrimSpace(blocksView); v != "" { + if !isKnownViewFilter(v) { + return fmt.Errorf("unknown --view %q; try one of: term, web, preview, edit, sysinfo", v) + } + } + + var allBlocks []BlockDetails + + workspaces, err := wshclient.WorkspaceListCommand(RpcClient, &wshrpc.RpcOpts{Timeout: int64(blocksTimeout)}) + if err != nil { + return fmt.Errorf("failed to list workspaces: %v", err) + } + + if len(workspaces) == 0 { + return fmt.Errorf("no workspaces found") + } + + var workspaceIdsToQuery []string + + // Determine which workspaces to query + if blocksWorkspaceId != "" && blocksWindowId != "" { + return fmt.Errorf("--workspace and --window are mutually exclusive; specify only one") + } + if blocksWorkspaceId != "" { + workspaceIdsToQuery = []string{blocksWorkspaceId} + } else if blocksWindowId != "" { + // Find workspace ID for this window + windowFound := false + for _, ws := range workspaces { + if ws.WindowId == blocksWindowId { + workspaceIdsToQuery = []string{ws.WorkspaceData.OID} + windowFound = true + break + } + } + if !windowFound { + return fmt.Errorf("window %s not found", blocksWindowId) + } + } else { + // Default to all workspaces + for _, ws := range workspaces { + workspaceIdsToQuery = append(workspaceIdsToQuery, ws.WorkspaceData.OID) + } + } + + // Query each selected workspace + hadSuccess := false + for _, wsId := range workspaceIdsToQuery { + req := wshrpc.BlocksListRequest{WorkspaceId: wsId} + if blocksWindowId != "" { + req.WindowId = blocksWindowId + } + + blocks, err := wshclient.BlocksListCommand(RpcClient, req, &wshrpc.RpcOpts{Timeout: int64(blocksTimeout)}) + if err != nil { + WriteStderr("Warning: couldn't list blocks for workspace %s: %v\n", wsId, err) + continue + } + hadSuccess = true + + // Apply filters + for _, b := range blocks { + if blocksTabId != "" && b.TabId != blocksTabId { + continue + } + + if blocksView != "" { + view := b.Meta.GetString(waveobj.MetaKey_View, "") + + // Support view type aliases + if !matchesViewType(view, blocksView) { + continue + } + } + + v := b.Meta.GetString(waveobj.MetaKey_View, "") + allBlocks = append(allBlocks, BlockDetails{ + BlockId: b.BlockId, + WorkspaceId: b.WorkspaceId, + TabId: b.TabId, + View: v, + Meta: b.Meta, + }) + } + } + + // No blocks found check + if len(allBlocks) == 0 { + if !hadSuccess { + return fmt.Errorf("failed to list blocks from all %d workspace(s)", len(workspaceIdsToQuery)) + } + WriteStdout("No blocks found\n") + return nil + } + + // Stable ordering for both JSON and table output + sort.SliceStable(allBlocks, func(i, j int) bool { + if allBlocks[i].WorkspaceId != allBlocks[j].WorkspaceId { + return allBlocks[i].WorkspaceId < allBlocks[j].WorkspaceId + } + if allBlocks[i].TabId != allBlocks[j].TabId { + return allBlocks[i].TabId < allBlocks[j].TabId + } + return allBlocks[i].BlockId < allBlocks[j].BlockId + }) + + // Output results + if blocksJSON { + bytes, err := json.MarshalIndent(allBlocks, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %v", err) + } + WriteStdout("%s\n", string(bytes)) + return nil + } + w := tabwriter.NewWriter(WrappedStdout, 0, 0, 2, ' ', 0) + defer w.Flush() + fmt.Fprintf(w, "BLOCK ID\tWORKSPACE\tTAB ID\tVIEW\tCONTENT\n") + + for _, b := range allBlocks { + blockID := b.BlockId + if len(blockID) > 36 { + blockID = blockID[:34] + ".." + } + view := strings.ToLower(b.View) + if view == "" { + view = "" + } + var content string + + switch view { + case "preview", "edit": + content = b.Meta.GetString(waveobj.MetaKey_File, "") + case "web": + content = b.Meta.GetString(waveobj.MetaKey_Url, "") + case "term": + content = b.Meta.GetString(waveobj.MetaKey_CmdCwd, "") + default: + content = "" + } + + wsID := b.WorkspaceId + if len(wsID) > 36 { + wsID = wsID[:34] + ".." + } + + tabID := b.TabId + if len(tabID) > 36 { + tabID = tabID[0:34] + ".." + } + + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", blockID, wsID, tabID, view, content) + } + + return nil +} + +// matchesViewType checks if a view type matches a filter, supporting aliases +func matchesViewType(actual, filter string) bool { + // Direct match (case insensitive) + if strings.EqualFold(actual, filter) { + return true + } + + // Handle aliases + switch strings.ToLower(filter) { + case "preview", "edit": + return strings.EqualFold(actual, "preview") || strings.EqualFold(actual, "edit") + case "terminal", "term", "shell", "console": + return strings.EqualFold(actual, "term") + case "web", "browser", "url": + return strings.EqualFold(actual, "web") + case "sys", "sysinfo", "system": + return strings.EqualFold(actual, "sysinfo") + } + + return false +} + +// isKnownViewFilter checks if a filter value is recognized +func isKnownViewFilter(f string) bool { + switch strings.ToLower(strings.TrimSpace(f)) { + case "term", "terminal", "shell", "console", + "web", "browser", "url", + "preview", "edit", + "sysinfo", "sys", "system": + return true + default: + return false + } +} diff --git a/cmd/wsh/cmd/wshcmd-conn.go b/cmd/wsh/cmd/wshcmd-conn.go new file mode 100644 index 000000000..9ef92d654 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-conn.go @@ -0,0 +1,212 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/remote" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +var connCmd = &cobra.Command{ + Use: "conn", + Short: "manage Wave Terminal connections", + Long: "Commands to manage Wave Terminal SSH and WSL connections", +} + +var connStatusCmd = &cobra.Command{ + Use: "status", + Short: "show status of all connections", + Args: cobra.NoArgs, + RunE: connStatusRun, + PreRunE: preRunSetupRpcClient, +} + +var connReinstallCmd = &cobra.Command{ + Use: "reinstall CONNECTION", + Short: "reinstall wsh on a connection", + RunE: connReinstallRun, + PreRunE: preRunSetupRpcClient, +} + +var connDisconnectCmd = &cobra.Command{ + Use: "disconnect CONNECTION", + Short: "disconnect a connection", + Args: cobra.ExactArgs(1), + RunE: connDisconnectRun, + PreRunE: preRunSetupRpcClient, +} + +var connDisconnectAllCmd = &cobra.Command{ + Use: "disconnectall", + Short: "disconnect all connections", + Args: cobra.NoArgs, + RunE: connDisconnectAllRun, + PreRunE: preRunSetupRpcClient, +} + +var connConnectCmd = &cobra.Command{ + Use: "connect CONNECTION", + Short: "connect to a connection", + Args: cobra.ExactArgs(1), + RunE: connConnectRun, + PreRunE: preRunSetupRpcClient, +} + +var connEnsureCmd = &cobra.Command{ + Use: "ensure CONNECTION", + Short: "ensure wsh is installed on a connection", + Args: cobra.ExactArgs(1), + RunE: connEnsureRun, + PreRunE: preRunSetupRpcClient, +} + +func init() { + rootCmd.AddCommand(connCmd) + connCmd.AddCommand(connStatusCmd) + connCmd.AddCommand(connReinstallCmd) + connCmd.AddCommand(connDisconnectCmd) + connCmd.AddCommand(connDisconnectAllCmd) + connCmd.AddCommand(connConnectCmd) + connCmd.AddCommand(connEnsureCmd) +} + +func validateConnectionName(name string) error { + if !strings.HasPrefix(name, "wsl://") { + _, err := remote.ParseOpts(name) + if err != nil { + return fmt.Errorf("cannot parse connection name: %w", err) + } + } + return nil +} + +func getAllConnStatus() ([]wshrpc.ConnStatus, error) { + var allResp []wshrpc.ConnStatus + sshResp, err := wshclient.ConnStatusCommand(RpcClient, nil) + if err != nil { + return nil, fmt.Errorf("getting ssh connection status: %w", err) + } + allResp = append(allResp, sshResp...) + wslResp, err := wshclient.WslStatusCommand(RpcClient, nil) + if err != nil { + return nil, fmt.Errorf("getting wsl connection status: %w", err) + } + allResp = append(allResp, wslResp...) + return allResp, nil +} + +func connStatusRun(cmd *cobra.Command, args []string) error { + allResp, err := getAllConnStatus() + if err != nil { + return err + } + if len(allResp) == 0 { + WriteStdout("no connections\n") + return nil + } + WriteStdout("%-30s %-12s\n", "connection", "status") + WriteStdout("----------------------------------------------\n") + for _, conn := range allResp { + str := fmt.Sprintf("%-30s %-12s", conn.Connection, conn.Status) + if conn.Error != "" { + str += fmt.Sprintf(" (%s)", conn.Error) + } + WriteStdout("%s\n", str) + } + return nil +} + +func connReinstallRun(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + if RpcContext.Conn == "" { + return fmt.Errorf("no connection specified") + } + args = []string{RpcContext.Conn} + } + connName := args[0] + if err := validateConnectionName(connName); err != nil { + return err + } + data := wshrpc.ConnExtData{ + ConnName: connName, + LogBlockId: RpcContext.BlockId, + } + err := wshclient.ConnReinstallWshCommand(RpcClient, data, &wshrpc.RpcOpts{Timeout: 60000}) + if err != nil { + return fmt.Errorf("reinstalling connection: %w", err) + } + WriteStdout("wsh reinstalled on connection %q\n", connName) + return nil +} + +func connDisconnectRun(cmd *cobra.Command, args []string) error { + connName := args[0] + if err := validateConnectionName(connName); err != nil { + return err + } + err := wshclient.ConnDisconnectCommand(RpcClient, connName, &wshrpc.RpcOpts{Timeout: 10000}) + if err != nil { + return fmt.Errorf("disconnecting %q error: %w", connName, err) + } + WriteStdout("disconnected %q\n", connName) + return nil +} + +func connDisconnectAllRun(cmd *cobra.Command, args []string) error { + allConns, err := getAllConnStatus() + if err != nil { + return err + } + for _, conn := range allConns { + if conn.Status != "connected" { + continue + } + err := wshclient.ConnDisconnectCommand(RpcClient, conn.Connection, &wshrpc.RpcOpts{Timeout: 10000}) + if err != nil { + WriteStdout("error disconnecting %q: %v\n", conn.Connection, err) + } else { + WriteStdout("disconnected %q\n", conn.Connection) + } + } + return nil +} + +func connConnectRun(cmd *cobra.Command, args []string) error { + connName := args[0] + if err := validateConnectionName(connName); err != nil { + return err + } + data := wshrpc.ConnRequest{ + Host: connName, + LogBlockId: RpcContext.BlockId, + } + err := wshclient.ConnConnectCommand(RpcClient, data, &wshrpc.RpcOpts{Timeout: 60000}) + if err != nil { + return fmt.Errorf("connecting connection: %w", err) + } + WriteStdout("connected connection %q\n", connName) + return nil +} + +func connEnsureRun(cmd *cobra.Command, args []string) error { + connName := args[0] + if err := validateConnectionName(connName); err != nil { + return err + } + data := wshrpc.ConnExtData{ + ConnName: connName, + LogBlockId: RpcContext.BlockId, + } + err := wshclient.ConnEnsureCommand(RpcClient, data, &wshrpc.RpcOpts{Timeout: 60000}) + if err != nil { + return fmt.Errorf("ensuring connection: %w", err) + } + WriteStdout("wsh ensured on connection %q\n", connName) + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-connserver.go b/cmd/wsh/cmd/wshcmd-connserver.go new file mode 100644 index 000000000..94e508a66 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-connserver.go @@ -0,0 +1,506 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "encoding/base64" + "fmt" + "io" + "log" + "net" + "os" + "path/filepath" + "strings" + "sync/atomic" + "time" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/baseds" + "github.com/s-zx/crest/pkg/panichandler" + "github.com/s-zx/crest/pkg/remote/fileshare/wshfs" + "github.com/s-zx/crest/pkg/util/envutil" + "github.com/s-zx/crest/pkg/util/packetparser" + "github.com/s-zx/crest/pkg/util/sigutil" + "github.com/s-zx/crest/pkg/wavebase" + "github.com/s-zx/crest/pkg/wavejwt" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" + "github.com/s-zx/crest/pkg/wshrpc/wshremote" + "github.com/s-zx/crest/pkg/wshutil" +) + +var serverCmd = &cobra.Command{ + Use: "connserver", + Hidden: true, + Short: "remote server to power wave blocks", + Args: cobra.NoArgs, + RunE: serverRun, +} + +const ( + JobLogRetentionTime = 48 * time.Hour + JobLogCleanupDelay = 10 * time.Second + JobLogCleanupInterval = 1 * time.Hour +) + +var connServerRouter bool +var connServerRouterDomainSocket bool +var connServerConnName string +var connServerDev bool +var ConnServerWshRouter *wshutil.WshRouter +var connServerInitialEnv map[string]string + +func init() { + serverCmd.Flags().BoolVar(&connServerRouter, "router", false, "run in local router mode (stdio upstream)") + serverCmd.Flags().BoolVar(&connServerRouterDomainSocket, "router-domainsocket", false, "run in local router mode (domain socket upstream)") + serverCmd.Flags().StringVar(&connServerConnName, "conn", "", "connection name") + serverCmd.Flags().BoolVar(&connServerDev, "dev", false, "enable dev mode with file logging and PID in logs") + rootCmd.AddCommand(serverCmd) +} + +func cleanupOldJobLogs() { + jobDir := wavebase.GetRemoteJobLogDir() + entries, err := os.ReadDir(jobDir) + if err != nil { + return + } + + cutoffTime := time.Now().Add(-JobLogRetentionTime) + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := entry.Name() + if !strings.HasSuffix(name, ".log") { + continue + } + + info, err := entry.Info() + if err != nil { + continue + } + + if info.ModTime().Before(cutoffTime) { + filePath := filepath.Join(jobDir, name) + err := os.Remove(filePath) + if err != nil { + log.Printf("error removing old job log file %s: %v", filePath, err) + } else { + log.Printf("removed old job log file: %s", filePath) + } + } + } +} + +func startJobLogCleanup() { + go func() { + defer func() { + panichandler.PanicHandler("startJobLogCleanup", recover()) + }() + + time.Sleep(JobLogCleanupDelay) + + cleanupOldJobLogs() + + ticker := time.NewTicker(JobLogCleanupInterval) + defer ticker.Stop() + + for range ticker.C { + cleanupOldJobLogs() + } + }() +} + +func getRemoteDomainSocketName() string { + homeDir := wavebase.GetHomeDir() + return filepath.Join(homeDir, wavebase.RemoteWaveHomeDirName, wavebase.RemoteDomainSocketBaseName) +} + +func MakeRemoteUnixListener() (net.Listener, error) { + serverAddr := getRemoteDomainSocketName() + os.Remove(serverAddr) // ignore error + rtn, err := net.Listen("unix", serverAddr) + if err != nil { + return nil, fmt.Errorf("error creating listener at %v: %v", serverAddr, err) + } + os.Chmod(serverAddr, 0700) + log.Printf("Server [unix-domain] listening on %s\n", serverAddr) + return rtn, nil +} + +func handleNewListenerConn(conn net.Conn, router *wshutil.WshRouter) { + defer func() { + panichandler.PanicHandler("handleNewListenerConn", recover()) + }() + var linkIdContainer atomic.Int32 + proxy := wshutil.MakeRpcProxy(fmt.Sprintf("connserver:%s", conn.RemoteAddr().String())) + go func() { + defer func() { + panichandler.PanicHandler("handleNewListenerConn:AdaptOutputChToStream", recover()) + }() + writeErr := wshutil.AdaptOutputChToStream(proxy.ToRemoteCh, conn) + if writeErr != nil { + log.Printf("error writing to domain socket: %v\n", writeErr) + } + }() + go func() { + // when input is closed, close the connection + defer func() { + panichandler.PanicHandler("handleNewListenerConn:AdaptStreamToMsgCh", recover()) + }() + defer func() { + conn.Close() + linkId := linkIdContainer.Load() + if linkId != baseds.NoLinkId { + router.UnregisterLink(baseds.LinkId(linkId)) + } + }() + wshutil.AdaptStreamToMsgCh(conn, proxy.FromRemoteCh, nil) + }() + linkId := router.RegisterUntrustedLink(proxy) + linkIdContainer.Store(int32(linkId)) +} + +func runListener(listener net.Listener, router *wshutil.WshRouter) { + defer func() { + log.Printf("listener closed, exiting\n") + time.Sleep(500 * time.Millisecond) + wshutil.DoShutdown("", 1, true) + }() + for { + conn, err := listener.Accept() + if err == io.EOF { + break + } + if err != nil { + log.Printf("error accepting connection: %v\n", err) + continue + } + go handleNewListenerConn(conn, router) + } +} + +func setupConnServerRpcClientWithRouter(router *wshutil.WshRouter, sockName string) (*wshutil.WshRpc, string, error) { + routeId := wshutil.MakeConnectionRouteId(connServerConnName) + rpcCtx := wshrpc.RpcContext{ + RouteId: routeId, + Conn: connServerConnName, + } + + bareRouteId := wshutil.MakeRandomProcRouteId() + bareClient := wshutil.MakeWshRpc(wshrpc.RpcContext{}, &wshclient.WshServer{}, bareRouteId) + router.RegisterTrustedLeaf(bareClient, bareRouteId) + + connServerClient := wshutil.MakeWshRpc(rpcCtx, wshremote.MakeRemoteRpcServerImpl(os.Stdout, router, bareClient, false, connServerInitialEnv, sockName), routeId) + router.RegisterTrustedLeaf(connServerClient, routeId) + return connServerClient, routeId, nil +} + +func serverRunRouter() error { + log.Printf("starting connserver router") + router := wshutil.NewWshRouter() + ConnServerWshRouter = router + termProxy := wshutil.MakeRpcProxy("connserver-term") + rawCh := make(chan []byte, wshutil.DefaultOutputChSize) + go func() { + defer func() { + panichandler.PanicHandler("serverRunRouter:Parse", recover()) + }() + packetparser.Parse(os.Stdin, termProxy.FromRemoteCh, rawCh) + }() + go func() { + defer func() { + panichandler.PanicHandler("serverRunRouter:WritePackets", recover()) + }() + for msg := range termProxy.ToRemoteCh { + packetparser.WritePacket(os.Stdout, msg) + } + }() + go func() { + defer func() { + panichandler.PanicHandler("serverRunRouter:drainRawCh", recover()) + }() + defer func() { + log.Printf("stdin closed, shutting down") + wshutil.DoShutdown("", 0, true) + }() + for range rawCh { + // ignore + } + }() + router.RegisterUpstream(termProxy) + + sockName := getRemoteDomainSocketName() + + // setup the connserver rpc client first + client, bareRouteId, err := setupConnServerRpcClientWithRouter(router, sockName) + if err != nil { + return fmt.Errorf("error setting up connserver rpc client: %v", err) + } + wshfs.RpcClient = client + wshfs.RpcClientRouteId = bareRouteId + + log.Printf("trying to get JWT public key") + + // fetch and set JWT public key + jwtPublicKeyB64, err := wshclient.GetJwtPublicKeyCommand(client, nil) + if err != nil { + return fmt.Errorf("error getting jwt public key: %v", err) + } + jwtPublicKeyBytes, err := base64.StdEncoding.DecodeString(jwtPublicKeyB64) + if err != nil { + return fmt.Errorf("error decoding jwt public key: %v", err) + } + err = wavejwt.SetPublicKey(jwtPublicKeyBytes) + if err != nil { + return fmt.Errorf("error setting jwt public key: %v", err) + } + + log.Printf("got JWT public key") + + // now set up the domain socket + unixListener, err := MakeRemoteUnixListener() + if err != nil { + return fmt.Errorf("cannot create unix listener: %v", err) + } + log.Printf("unix listener started") + go func() { + defer func() { + panichandler.PanicHandler("serverRunRouter:runListener", recover()) + }() + runListener(unixListener, router) + }() + // run the sysinfo loop + go func() { + defer func() { + panichandler.PanicHandler("serverRunRouter:RunSysInfoLoop", recover()) + }() + wshremote.RunSysInfoLoop(client, connServerConnName) + }() + startJobLogCleanup() + log.Printf("running server, successfully started") + select {} +} + +func serverRunRouterDomainSocket(jwtToken string) error { + log.Printf("starting connserver router (domain socket upstream)") + + // extract socket name from JWT token (unverified - we're on the client side) + sockName, err := wshutil.ExtractUnverifiedSocketName(jwtToken) + if err != nil { + return fmt.Errorf("error extracting socket name from JWT: %v", err) + } + + // connect to the forwarded domain socket + sockName = wavebase.ExpandHomeDirSafe(sockName) + conn, err := net.Dial("unix", sockName) + if err != nil { + return fmt.Errorf("error connecting to domain socket %s: %v", sockName, err) + } + + // create router + router := wshutil.NewWshRouter() + ConnServerWshRouter = router + + // create proxy for the domain socket connection + upstreamProxy := wshutil.MakeRpcProxy("connserver-upstream") + + // goroutine to write to the domain socket + go func() { + defer func() { + panichandler.PanicHandler("serverRunRouterDomainSocket:WriteLoop", recover()) + }() + writeErr := wshutil.AdaptOutputChToStream(upstreamProxy.ToRemoteCh, conn) + if writeErr != nil { + log.Printf("error writing to upstream domain socket: %v\n", writeErr) + } + }() + + // goroutine to read from the domain socket + go func() { + defer func() { + panichandler.PanicHandler("serverRunRouterDomainSocket:ReadLoop", recover()) + }() + defer func() { + log.Printf("upstream domain socket closed, shutting down") + wshutil.DoShutdown("", 0, true) + }() + wshutil.AdaptStreamToMsgCh(conn, upstreamProxy.FromRemoteCh, nil) + }() + + // register the domain socket connection as upstream + router.RegisterUpstream(upstreamProxy) + + // use the router's control RPC to authenticate with upstream + controlRpc := router.GetControlRpc() + + // authenticate with the upstream router using the JWT + _, err = wshclient.AuthenticateCommand(controlRpc, jwtToken, &wshrpc.RpcOpts{Route: wshutil.ControlRootRoute}) + if err != nil { + return fmt.Errorf("error authenticating with upstream: %v", err) + } + log.Printf("authenticated with upstream router") + + // fetch and set JWT public key + log.Printf("trying to get JWT public key") + jwtPublicKeyB64, err := wshclient.GetJwtPublicKeyCommand(controlRpc, nil) + if err != nil { + return fmt.Errorf("error getting jwt public key: %v", err) + } + jwtPublicKeyBytes, err := base64.StdEncoding.DecodeString(jwtPublicKeyB64) + if err != nil { + return fmt.Errorf("error decoding jwt public key: %v", err) + } + err = wavejwt.SetPublicKey(jwtPublicKeyBytes) + if err != nil { + return fmt.Errorf("error setting jwt public key: %v", err) + } + log.Printf("got JWT public key") + + // now setup the connserver rpc client + client, bareRouteId, err := setupConnServerRpcClientWithRouter(router, sockName) + if err != nil { + return fmt.Errorf("error setting up connserver rpc client: %v", err) + } + wshfs.RpcClient = client + wshfs.RpcClientRouteId = bareRouteId + + // set up the local domain socket listener for local wsh commands + unixListener, err := MakeRemoteUnixListener() + if err != nil { + return fmt.Errorf("cannot create unix listener: %v", err) + } + log.Printf("unix listener started") + go func() { + defer func() { + panichandler.PanicHandler("serverRunRouterDomainSocket:runListener", recover()) + }() + runListener(unixListener, router) + }() + + // run the sysinfo loop + go func() { + defer func() { + panichandler.PanicHandler("serverRunRouterDomainSocket:RunSysInfoLoop", recover()) + }() + wshremote.RunSysInfoLoop(client, connServerConnName) + }() + startJobLogCleanup() + + log.Printf("running server (router-domainsocket mode), successfully started") + select {} +} + +func serverRunNormal(jwtToken string) error { + sockName, err := wshutil.ExtractUnverifiedSocketName(jwtToken) + if err != nil { + return fmt.Errorf("error extracting socket name from JWT: %v", err) + } + err = setupRpcClient(wshremote.MakeRemoteRpcServerImpl(os.Stdout, nil, nil, false, connServerInitialEnv, sockName), jwtToken) + if err != nil { + return err + } + wshfs.RpcClient = RpcClient + wshfs.RpcClientRouteId = RpcClientRouteId + WriteStdout("running wsh connserver (%s)\n", RpcContext.Conn) + go func() { + defer func() { + panichandler.PanicHandler("serverRunNormal:RunSysInfoLoop", recover()) + }() + wshremote.RunSysInfoLoop(RpcClient, RpcContext.Conn) + }() + startJobLogCleanup() + select {} // run forever +} + +func askForJwtToken() (string, error) { + // if it already exists in the environment, great, use it + jwtToken := os.Getenv(wavebase.WaveJwtTokenVarName) + if jwtToken != "" { + fmt.Printf("HAVE-JWT\n") + return jwtToken, nil + } + + // otherwise, ask for it + fmt.Printf("%s\n", wavebase.NeedJwtConst) + + // read a single line from stdin + var line string + _, err := fmt.Fscanln(os.Stdin, &line) + if err != nil { + return "", fmt.Errorf("failed to read JWT token from stdin: %w", err) + } + return strings.TrimSpace(line), nil +} + +func serverRun(cmd *cobra.Command, args []string) error { + connServerInitialEnv = envutil.PruneInitialEnv(envutil.SliceToMap(os.Environ())) + + var logFile *os.File + if connServerDev { + var err error + logFilePath := fmt.Sprintf("/tmp/waveterm-connserver-%d.log", os.Getuid()) + logFile, err = os.OpenFile(logFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to open log file: %v\n", err) + log.SetFlags(log.LstdFlags | log.Lmicroseconds) + log.SetPrefix(fmt.Sprintf("[PID:%d] ", os.Getpid())) + } else { + defer logFile.Close() + logWriter := io.MultiWriter(os.Stderr, logFile) + log.SetOutput(logWriter) + log.SetFlags(log.LstdFlags | log.Lmicroseconds) + log.SetPrefix(fmt.Sprintf("[PID:%d] ", os.Getpid())) + } + } + if connServerConnName == "" { + if logFile != nil { + fmt.Fprintf(logFile, "--conn parameter is required\n") + } + return fmt.Errorf("--conn parameter is required") + } + installErr := wshutil.InstallRcFiles() + if installErr != nil { + if logFile != nil { + fmt.Fprintf(logFile, "error installing rc files: %v\n", installErr) + } + log.Printf("error installing rc files: %v", installErr) + } + sigutil.InstallSIGUSR1Handler() + if connServerRouter { + err := serverRunRouter() + if err != nil && logFile != nil { + fmt.Fprintf(logFile, "serverRunRouter error: %v\n", err) + } + return err + } + if connServerRouterDomainSocket { + jwtToken, err := askForJwtToken() + if err != nil { + if logFile != nil { + fmt.Fprintf(logFile, "askForJwtToken error: %v\n", err) + } + return err + } + err = serverRunRouterDomainSocket(jwtToken) + if err != nil && logFile != nil { + fmt.Fprintf(logFile, "serverRunRouterDomainSocket error: %v\n", err) + } + return err + } + jwtToken, err := askForJwtToken() + if err != nil { + if logFile != nil { + fmt.Fprintf(logFile, "askForJwtToken error: %v\n", err) + } + return err + } + err = serverRunNormal(jwtToken) + if err != nil && logFile != nil { + fmt.Fprintf(logFile, "serverRunNormal error: %v\n", err) + } + return err +} diff --git a/cmd/wsh/cmd/wshcmd-createblock.go b/cmd/wsh/cmd/wshcmd-createblock.go new file mode 100644 index 000000000..308b674b3 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-createblock.go @@ -0,0 +1,60 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/waveobj" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +var createBlockMagnified bool + +var createBlockCmd = &cobra.Command{ + Use: "createblock viewname key=value ...", + Short: "create a new block", + Args: cobra.MinimumNArgs(1), + RunE: createBlockRun, + PreRunE: preRunSetupRpcClient, + Hidden: true, +} + +func init() { + createBlockCmd.Flags().BoolVarP(&createBlockMagnified, "magnified", "m", false, "create block in magnified mode") + rootCmd.AddCommand(createBlockCmd) +} + +func createBlockRun(cmd *cobra.Command, args []string) error { + viewName := args[0] + var metaSetStrs []string + if len(args) > 1 { + metaSetStrs = args[1:] + } + tabId := getTabIdFromEnv() + if tabId == "" { + return fmt.Errorf("no WAVETERM_TABID env var set") + } + meta, err := parseMetaSets(metaSetStrs) + if err != nil { + return err + } + meta["view"] = viewName + data := wshrpc.CommandCreateBlockData{ + TabId: tabId, + BlockDef: &waveobj.BlockDef{ + Meta: meta, + }, + Magnified: createBlockMagnified, + Focused: true, + } + oref, err := wshclient.CreateBlockCommand(RpcClient, data, nil) + if err != nil { + return fmt.Errorf("create block failed: %v", err) + } + fmt.Printf("created block %s\n", oref.OID) + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-debug.go b/cmd/wsh/cmd/wshcmd-debug.go new file mode 100644 index 000000000..9521b49d3 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-debug.go @@ -0,0 +1,60 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "encoding/json" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +var debugCmd = &cobra.Command{ + Use: "debug", + Short: "debug commands", + PersistentPreRunE: preRunSetupRpcClient, + Hidden: true, +} + +var debugBlockIdsCmd = &cobra.Command{ + Use: "block", + Short: "list sub-blockids for block", + RunE: debugBlockIdsRun, + Hidden: true, +} + +var debugSendTelemetryCmd = &cobra.Command{ + Use: "send-telemetry", + Short: "send telemetry", + RunE: debugSendTelemetryRun, + Hidden: true, +} + +func init() { + debugCmd.AddCommand(debugBlockIdsCmd) + debugCmd.AddCommand(debugSendTelemetryCmd) + rootCmd.AddCommand(debugCmd) +} + +func debugSendTelemetryRun(cmd *cobra.Command, args []string) error { + err := wshclient.SendTelemetryCommand(RpcClient, nil) + return err +} + +func debugBlockIdsRun(cmd *cobra.Command, args []string) error { + oref, err := resolveBlockArg() + if err != nil { + return err + } + blockInfo, err := wshclient.BlockInfoCommand(RpcClient, oref.OID, nil) + if err != nil { + return err + } + barr, err := json.MarshalIndent(blockInfo, "", " ") + if err != nil { + return err + } + WriteStdout("%s\n", string(barr)) + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-debugterm.go b/cmd/wsh/cmd/wshcmd-debugterm.go new file mode 100644 index 000000000..eb5cbadae --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-debugterm.go @@ -0,0 +1,551 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "strconv" + "strings" + "unicode/utf8" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +const ( + DebugTermModeHex = "hex" + DebugTermModeDecode = "decode" +) + +var debugTermCmd = &cobra.Command{ + Use: "debugterm", + Short: "inspect recent terminal output bytes", + RunE: debugTermRun, + PreRunE: debugTermPreRun, + DisableFlagsInUseLine: true, + Hidden: true, +} + +var ( + debugTermSize int64 + debugTermMode string + debugTermStdin bool + debugTermInput string +) + +func init() { + rootCmd.AddCommand(debugTermCmd) + debugTermCmd.Flags().Int64Var(&debugTermSize, "size", 1000, "number of terminal bytes to read") + debugTermCmd.Flags().StringVar(&debugTermMode, "mode", DebugTermModeHex, "output mode: hex or decode") + debugTermCmd.Flags().BoolVar(&debugTermStdin, "stdin", false, "read input from stdin instead of rpc call") + debugTermCmd.Flags().StringVar(&debugTermInput, "input", "", "read input from file instead of rpc call") +} + +func debugTermRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("debugterm", rtnErr == nil) + }() + mode, err := getDebugTermMode() + if err != nil { + return err + } + if debugTermStdin { + stdinData, err := io.ReadAll(WrappedStdin) + if err != nil { + return fmt.Errorf("reading stdin: %w", err) + } + termData, err := parseDebugTermStdinData(stdinData) + if err != nil { + return err + } + if mode == DebugTermModeDecode { + WriteStdout("%s", formatDebugTermDecode(termData)) + } else { + WriteStdout("%s", formatDebugTermHex(termData)) + } + return nil + } + if debugTermInput != "" { + fileData, err := os.ReadFile(debugTermInput) + if err != nil { + return fmt.Errorf("reading input file: %w", err) + } + termData, err := parseDebugTermStdinData(fileData) + if err != nil { + return err + } + if mode == DebugTermModeDecode { + WriteStdout("%s", formatDebugTermDecode(termData)) + } else { + WriteStdout("%s", formatDebugTermHex(termData)) + } + return nil + } + if debugTermSize <= 0 { + return fmt.Errorf("size must be greater than 0") + } + fullORef, err := resolveBlockArg() + if err != nil { + return err + } + rtn, err := wshclient.DebugTermCommand(RpcClient, wshrpc.CommandDebugTermData{ + BlockId: fullORef.OID, + Size: debugTermSize, + }, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("reading terminal output: %w", err) + } + termData, err := base64.StdEncoding.DecodeString(rtn.Data64) + if err != nil { + return fmt.Errorf("decoding terminal output: %w", err) + } + var output string + if mode == DebugTermModeDecode { + output = formatDebugTermDecode(termData) + } else { + output = formatDebugTermHex(termData) + } + WriteStdout("%s", output) + return nil +} + +func debugTermPreRun(cmd *cobra.Command, args []string) error { + if debugTermStdin || debugTermInput != "" { + return nil + } + return preRunSetupRpcClient(cmd, args) +} + +func getDebugTermMode() (string, error) { + mode := strings.ToLower(debugTermMode) + if mode != DebugTermModeHex && mode != DebugTermModeDecode { + return "", fmt.Errorf("invalid mode %q (expected %q or %q)", debugTermMode, DebugTermModeHex, DebugTermModeDecode) + } + return mode, nil +} + +type debugTermStdinEntry struct { + Data string `json:"data"` +} + +func parseDebugTermStdinData(data []byte) ([]byte, error) { + trimmed := strings.TrimSpace(string(data)) + if len(trimmed) == 0 { + return data, nil + } + if trimmed[0] == '[' { + // try array of structs first + var structArr []debugTermStdinEntry + err := json.Unmarshal(data, &structArr) + if err == nil { + parts := make([]string, len(structArr)) + for i, entry := range structArr { + parts[i] = entry.Data + } + return []byte(strings.Join(parts, "")), nil + } + fmt.Fprintf(os.Stderr, "json read err %v\n", err) + // try array of strings + var strArr []string + err = json.Unmarshal(data, &strArr) + if err == nil { + return []byte(strings.Join(strArr, "")), nil + } + } + return data, nil +} + +func formatDebugTermHex(data []byte) string { + return hex.Dump(data) +} + +func parseCursorForwardN(seq []byte) (int, bool) { + if len(seq) < 3 || seq[len(seq)-1] != 'C' { + return 0, false + } + params := string(seq[2 : len(seq)-1]) + if params == "" { + return 1, true + } + n, err := strconv.Atoi(params) + if err != nil || n <= 0 { + return 0, false + } + return n, true +} + +// splitOnCRLFRuns splits s at the end of each run of \r and \n characters. +// Each segment includes its trailing CR/LF run. The last segment may have no such run. +func splitOnCRLFRuns(s string) []string { + var result []string + for len(s) > 0 { + // find start of next CR/LF run + i := 0 + for i < len(s) && s[i] != '\r' && s[i] != '\n' { + i++ + } + if i == len(s) { + break + } + // consume the CR/LF run + j := i + for j < len(s) && (s[j] == '\r' || s[j] == '\n') { + j++ + } + result = append(result, s[:j]) + s = s[j:] + } + if len(s) > 0 { + result = append(result, s) + } + return result +} + +func formatDebugTermDecode(data []byte) string { + if len(data) == 0 { + return "" + } + lines := make([]string, 0) + // textBuf accumulates text across CSI-C (cursor forward) sequences so consecutive + // "word CSI-C word" runs collapse into a single TXT line. The // NC annotation goes + // on the last segment only. + textBuf := "" + totalCSpaces := 0 + flushText := func() { + if textBuf == "" && totalCSpaces == 0 { + return + } + segs := splitOnCRLFRuns(textBuf) + if len(segs) == 0 { + segs = []string{textBuf} + } + for i, seg := range segs { + if i == len(segs)-1 && totalCSpaces > 0 { + lines = append(lines, fmt.Sprintf("TXT %s // %dC", strconv.Quote(seg), totalCSpaces)) + } else { + lines = append(lines, "TXT "+strconv.Quote(seg)) + } + } + textBuf = "" + totalCSpaces = 0 + } + for i := 0; i < len(data); { + b := data[i] + if b == 0x1b { + if i+1 >= len(data) { + flushText() + lines = append(lines, "ESC") + i++ + continue + } + next := data[i+1] + switch next { + case '[': + seq, end := consumeDebugTermCSI(data, i) + if n, ok := parseCursorForwardN(seq); ok { + textBuf += strings.Repeat(" ", n) + totalCSpaces += n + } else { + flushText() + lines = append(lines, formatDebugTermCSILine(seq)) + } + i = end + case ']': + flushText() + seq, end := consumeDebugTermOSC(data, i) + lines = append(lines, formatDebugTermOSCLine(seq)) + i = end + case 'P': + flushText() + seq, end := consumeDebugTermST(data, i) + lines = append(lines, "DCS "+strconv.QuoteToASCII(string(seq))) + i = end + case '^': + flushText() + seq, end := consumeDebugTermST(data, i) + lines = append(lines, "PM "+strconv.QuoteToASCII(string(seq))) + i = end + case '_': + flushText() + seq, end := consumeDebugTermST(data, i) + lines = append(lines, "APC "+strconv.QuoteToASCII(string(seq))) + i = end + default: + flushText() + seq := data[i : i+2] + lines = append(lines, "ESC "+strconv.QuoteToASCII(string(seq))) + i += 2 + } + continue + } + if b == 0x07 { + flushText() + lines = append(lines, "BEL") + i++ + continue + } + start, end := consumeDebugTermText(data, i) + if end > start { + textBuf += string(data[start:end]) + i = end + continue + } + flushText() + lines = append(lines, fmt.Sprintf("CTL 0x%02x", b)) + i++ + } + flushText() + return strings.Join(lines, "\n") + "\n" +} + +var csiCommandDescriptions = map[byte]string{ + '@': "insert character", + 'A': "cursor up", + 'B': "cursor down", + 'C': "cursor forward", + 'D': "cursor back", + 'E': "cursor next line", + 'F': "cursor prev line", + 'G': "cursor horizontal absolute", + 'H': "cursor position", + 'I': "cursor horizontal tab", + 'J': "erase display", + 'K': "erase line", + 'L': "insert line", + 'M': "delete line", + 'P': "delete character", + 'S': "scroll up", + 'T': "scroll down", + 'X': "erase character", + 'Z': "cursor backward tab", + 'a': "cursor horizontal relative", + 'b': "repeat character", + 'c': "device attributes", + 'd': "cursor vertical absolute", + 'e': "cursor vertical relative", + 'f': "horizontal vertical position", + 'g': "tab clear", + 'h': "set mode", + 'l': "reset mode", + 'm': "SGR", + 'n': "device status report", + 'r': "set scrolling region", + 's': "save cursor", + 'u': "restore cursor", +} + +var decModeDescriptions = map[string]string{ + "1": "application cursor keys", + "3": "132 column mode", + "6": "origin mode", + "7": "auto wrap", + "12": "blinking cursor", + "25": "show cursor", + "47": "alternate screen", + "1000": "mouse X10 tracking", + "1002": "mouse button events", + "1003": "mouse all events", + "1004": "focus events", + "1006": "SGR mouse mode", + "1049": "alt screen + save cursor", + "2004": "bracketed paste", + "2026": "synchronized output", +} + +var sgrSingleDescriptions = map[int]string{ + 0: "reset all", + 1: "bold", + 2: "dim", + 3: "italic", + 4: "underline", + 5: "blink", + 7: "reverse", + 8: "hidden", + 9: "strikethrough", + 21: "doubly underlined", + 22: "normal intensity", + 23: "not italic", + 24: "not underlined", + 25: "not blinking", + 27: "not reversed", + 28: "not hidden", + 29: "not strikethrough", + 39: "default fg", + 49: "default bg", +} + +func describeSGR(params string) string { + if params == "" { + return "reset all" + } + parts := strings.Split(params, ";") + if len(parts) >= 5 && parts[0] == "38" && parts[1] == "2" { + return fmt.Sprintf("fg rgb(%s,%s,%s)", parts[2], parts[3], parts[4]) + } + if len(parts) >= 5 && parts[0] == "48" && parts[1] == "2" { + return fmt.Sprintf("bg rgb(%s,%s,%s)", parts[2], parts[3], parts[4]) + } + if len(parts) == 3 && parts[0] == "38" && parts[1] == "5" { + return fmt.Sprintf("fg color256(%s)", parts[2]) + } + if len(parts) == 3 && parts[0] == "48" && parts[1] == "5" { + return fmt.Sprintf("bg color256(%s)", parts[2]) + } + if len(parts) != 1 { + return "" + } + n, err := strconv.Atoi(parts[0]) + if err != nil { + return "" + } + if desc, ok := sgrSingleDescriptions[n]; ok { + return desc + } + if n >= 30 && n <= 37 { + return fmt.Sprintf("fg ansi color %d", n-30) + } + if n >= 40 && n <= 47 { + return fmt.Sprintf("bg ansi color %d", n-40) + } + if n >= 90 && n <= 97 { + return fmt.Sprintf("fg bright color %d", n-90) + } + if n >= 100 && n <= 107 { + return fmt.Sprintf("bg bright color %d", n-100) + } + return "" +} + +func formatDebugTermCSILine(seq []byte) string { + // seq is the full sequence starting with ESC [ + if len(seq) < 3 { + return "CSI " + strconv.QuoteToASCII(string(seq)) + } + inner := seq[2:] + finalByte := inner[len(inner)-1] + params := string(inner[:len(inner)-1]) + + // DEC private mode: params starts with "?" and final byte is 'h' (set) or 'l' (reset) + if strings.HasPrefix(params, "?") && (finalByte == 'h' || finalByte == 'l') { + modeStr := params[1:] + var line string + if finalByte == 'h' { + line = "DEC SET " + modeStr + } else { + line = "DEC RST " + modeStr + } + if desc, ok := decModeDescriptions[modeStr]; ok { + line += " // " + desc + } + return line + } + + finalStr := string([]byte{finalByte}) + var line string + if params == "" { + line = "CSI " + finalStr + } else { + line = "CSI " + finalStr + " " + params + } + if finalByte == 'm' { + if desc := describeSGR(params); desc != "" { + line += " // " + desc + } + } else if desc, ok := csiCommandDescriptions[finalByte]; ok { + line += " // " + desc + } + return line +} + +func consumeDebugTermCSI(data []byte, start int) ([]byte, int) { + i := start + 2 + for i < len(data) { + if data[i] >= 0x40 && data[i] <= 0x7e { + return data[start : i+1], i + 1 + } + i++ + } + return data[start:], len(data) +} + +func formatDebugTermOSCLine(seq []byte) string { + // seq is the full sequence starting with ESC ] + if len(seq) < 3 { + return "OSC " + strconv.QuoteToASCII(string(seq)) + } + // strip ESC ] prefix + inner := string(seq[2:]) + // strip trailing BEL or ST (ESC \) + inner = strings.TrimSuffix(inner, "\x07") + inner = strings.TrimSuffix(inner, "\x1b\\") + // split code from data on first ; + if idx := strings.IndexByte(inner, ';'); idx >= 0 { + code := inner[:idx] + data := inner[idx+1:] + return "OSC " + code + " " + strconv.QuoteToASCII(data) + } + return "OSC " + strconv.QuoteToASCII(inner) +} + +func consumeDebugTermOSC(data []byte, start int) ([]byte, int) { + i := start + 2 + for i < len(data) { + if data[i] == 0x07 { + return data[start : i+1], i + 1 + } + if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' { + return data[start : i+2], i + 2 + } + i++ + } + return data[start:], len(data) +} + +func consumeDebugTermST(data []byte, start int) ([]byte, int) { + i := start + 2 + for i < len(data) { + if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' { + return data[start : i+2], i + 2 + } + i++ + } + return data[start:], len(data) +} + +func isDebugTermC0Control(b byte) bool { + return b < 0x20 || b == 0x7f +} + +func consumeDebugTermText(data []byte, i int) (start, end int) { + start = i + for i < len(data) { + b := data[i] + if b == 0x1b || b == 0x07 { + break + } + if b == '\n' || b == '\r' || b == '\t' { + i++ + continue + } + if isDebugTermC0Control(b) { + break + } + if b < 0x80 { + i++ + continue + } + _, sz := utf8.DecodeRune(data[i:]) + if sz == 1 { + break + } + i += sz + } + return start, i +} diff --git a/cmd/wsh/cmd/wshcmd-debugterm_test.go b/cmd/wsh/cmd/wshcmd-debugterm_test.go new file mode 100644 index 000000000..eba2caeb7 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-debugterm_test.go @@ -0,0 +1,100 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "strings" + "testing" +) + +func TestFormatDebugTermHex(t *testing.T) { + output := formatDebugTermHex([]byte("abc")) + if !strings.Contains(output, "61 62 63") { + t.Fatalf("unexpected hex output: %q", output) + } +} + +func TestFormatDebugTermDecode(t *testing.T) { + data := []byte("abc\x1b[31mred\x1b[0m\x07\x1b]0;title\x07\x00") + output := formatDebugTermDecode(data) + expected := []string{ + `TXT "abc"`, + `CSI m 31`, + `TXT "red"`, + `CSI m 0`, + `BEL`, + `OSC 0 "title"`, + `CTL 0x00`, + } + for _, line := range expected { + if !strings.Contains(output, line) { + t.Fatalf("missing decode line %q in output %q", line, output) + } + } +} + +func TestParseDebugTermStdinData(t *testing.T) { + data, err := parseDebugTermStdinData([]byte(`["abc","\u001b[31mred","\u001b[0m"]`)) + if err != nil { + t.Fatalf("parseDebugTermStdinData() error: %v", err) + } + output := formatDebugTermDecode(data) + expected := []string{ + `TXT "abc"`, + `CSI m 31`, + `TXT "red"`, + `CSI m 0`, + } + for _, line := range expected { + if !strings.Contains(output, line) { + t.Fatalf("missing decode line %q in output %q", line, output) + } + } +} + +func TestParseDebugTermStdinDataStructs(t *testing.T) { + data, err := parseDebugTermStdinData([]byte(`[{"data":"abc"},{"data":"\u001b[31mred"},{"data":"\u001b[0m"}]`)) + if err != nil { + t.Fatalf("parseDebugTermStdinData() error: %v", err) + } + output := formatDebugTermDecode(data) + expected := []string{ + `TXT "abc"`, + `CSI m 31`, + `TXT "red"`, + `CSI m 0`, + } + for _, line := range expected { + if !strings.Contains(output, line) { + t.Fatalf("missing decode line %q in output %q", line, output) + } + } +} + +func TestFormatDebugTermDecodeCursorForward(t *testing.T) { + // CSI C sequences collapse into adjacent text; all consecutive text+CSI-C runs merge into one TXT line. + // The run is split into separate TXT lines at CR/LF run boundaries; // NC appears on the last line. + data := []byte("hi\x1b[1Cworld\x1b[3Cfoo\r\nbar") + output := formatDebugTermDecode(data) + expected := []string{ + `TXT "hi world foo\r\n"`, + `TXT "bar" // 4C`, + } + for _, line := range expected { + if !strings.Contains(output, line) { + t.Fatalf("missing decode line %q in output:\n%s", line, output) + } + } +} + +func TestParseDebugTermStdinDataRaw(t *testing.T) { + raw := []byte("hello\x1b[31mworld") + data, err := parseDebugTermStdinData(raw) + if err != nil { + t.Fatalf("parseDebugTermStdinData() error: %v", err) + } + if string(data) != string(raw) { + t.Fatalf("expected raw passthrough, got %q", data) + } +} diff --git a/cmd/wsh/cmd/wshcmd-deleteblock.go b/cmd/wsh/cmd/wshcmd-deleteblock.go new file mode 100644 index 000000000..acd8e0121 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-deleteblock.go @@ -0,0 +1,45 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +var deleteBlockCmd = &cobra.Command{ + Use: "deleteblock", + Short: "delete a block", + RunE: deleteBlockRun, + PreRunE: preRunSetupRpcClient, +} + +func init() { + rootCmd.AddCommand(deleteBlockCmd) +} + +func deleteBlockRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("deleteblock", rtnErr == nil) + }() + fullORef, err := resolveBlockArg() + if err != nil { + return err + } + if fullORef.OType != "block" { + return fmt.Errorf("object reference is not a block") + } + deleteBlockData := &wshrpc.CommandDeleteBlockData{ + BlockId: fullORef.OID, + } + err = wshclient.DeleteBlockCommand(RpcClient, *deleteBlockData, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("delete block failed: %v", err) + } + WriteStdout("block deleted\n") + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-editconfig.go b/cmd/wsh/cmd/wshcmd-editconfig.go new file mode 100644 index 000000000..5bcbf0256 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-editconfig.go @@ -0,0 +1,63 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/waveobj" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +var editConfigMagnified bool + +var editConfigCmd = &cobra.Command{ + Use: "editconfig [configfile]", + Short: "edit Wave configuration files", + Long: "Edit Wave configuration files. Defaults to settings.json if no file specified. Common files: settings.json, presets.json, widgets.json", + Args: cobra.MaximumNArgs(1), + RunE: editConfigRun, + PreRunE: preRunSetupRpcClient, +} + +func init() { + editConfigCmd.Flags().BoolVarP(&editConfigMagnified, "magnified", "m", false, "open config in magnified mode") + rootCmd.AddCommand(editConfigCmd) +} + +func editConfigRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("editconfig", rtnErr == nil) + }() + + configFile := "settings.json" // default + if len(args) > 0 { + configFile = args[0] + } + + tabId := getTabIdFromEnv() + if tabId == "" { + return fmt.Errorf("no WAVETERM_TABID env var set") + } + + wshCmd := &wshrpc.CommandCreateBlockData{ + TabId: tabId, + BlockDef: &waveobj.BlockDef{ + Meta: map[string]interface{}{ + waveobj.MetaKey_View: "waveconfig", + waveobj.MetaKey_File: configFile, + }, + }, + Magnified: editConfigMagnified, + Focused: true, + } + + _, err := wshclient.CreateBlockCommand(RpcClient, *wshCmd, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("opening config file: %w", err) + } + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-editor.go b/cmd/wsh/cmd/wshcmd-editor.go new file mode 100644 index 000000000..0bc1791d2 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-editor.go @@ -0,0 +1,91 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/waveobj" + "github.com/s-zx/crest/pkg/wps" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +var editMagnified bool + +var editorCmd = &cobra.Command{ + Use: "editor", + Short: "edit a file (blocks until editor is closed)", + RunE: editorRun, + PreRunE: preRunSetupRpcClient, +} + +func init() { + editorCmd.Flags().BoolVarP(&editMagnified, "magnified", "m", false, "open view in magnified mode") + rootCmd.AddCommand(editorCmd) +} + +func editorRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("editor", rtnErr == nil) + }() + if len(args) == 0 { + OutputHelpMessage(cmd) + return fmt.Errorf("no arguments. wsh editor requires a file or URL as an argument argument") + } + if len(args) > 1 { + OutputHelpMessage(cmd) + return fmt.Errorf("too many arguments. wsh editor requires exactly one argument") + } + fileArg := args[0] + absFile, err := filepath.Abs(fileArg) + if err != nil { + return fmt.Errorf("getting absolute path: %w", err) + } + _, err = os.Stat(absFile) + if err == fs.ErrNotExist { + return fmt.Errorf("file does not exist: %q", absFile) + } + if err != nil { + return fmt.Errorf("getting file info: %w", err) + } + + tabId := getTabIdFromEnv() + if tabId == "" { + return fmt.Errorf("no WAVETERM_TABID env var set") + } + + wshCmd := wshrpc.CommandCreateBlockData{ + TabId: tabId, + BlockDef: &waveobj.BlockDef{ + Meta: map[string]any{ + waveobj.MetaKey_View: "preview", + waveobj.MetaKey_File: absFile, + waveobj.MetaKey_Edit: true, + }, + }, + Magnified: editMagnified, + Focused: true, + } + if RpcContext.Conn != "" { + wshCmd.BlockDef.Meta[waveobj.MetaKey_Connection] = RpcContext.Conn + } + blockRef, err := wshclient.CreateBlockCommand(RpcClient, wshCmd, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("running view command: %w", err) + } + doneCh := make(chan bool) + RpcClient.EventListener.On(wps.Event_BlockClose, func(event *wps.WaveEvent) { + if event.HasScope(blockRef.String()) { + close(doneCh) + } + }) + wshclient.EventSubCommand(RpcClient, wps.SubscriptionRequest{Event: wps.Event_BlockClose, Scopes: []string{blockRef.String()}}, nil) + <-doneCh + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-file-util.go b/cmd/wsh/cmd/wshcmd-file-util.go new file mode 100644 index 000000000..f8470dd57 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-file-util.go @@ -0,0 +1,147 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "context" + "encoding/base64" + "fmt" + "io" + "io/fs" + "strings" + + "github.com/s-zx/crest/pkg/remote/connparse" + "github.com/s-zx/crest/pkg/util/fileutil" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" + "github.com/s-zx/crest/pkg/wshutil" +) + +func convertNotFoundErr(err error) error { + if err == nil { + return nil + } + if strings.HasPrefix(err.Error(), "NOTFOUND:") { + return fs.ErrNotExist + } + return err +} + +func ensureFile(fileData wshrpc.FileData) (*wshrpc.FileInfo, error) { + info, err := wshclient.FileInfoCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout}) + err = convertNotFoundErr(err) + if err == fs.ErrNotExist { + err = wshclient.FileCreateCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout}) + if err != nil { + return nil, fmt.Errorf("creating file: %w", err) + } + info, err = wshclient.FileInfoCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout}) + if err != nil { + return nil, fmt.Errorf("getting file info: %w", err) + } + return info, err + } + if err != nil { + return nil, fmt.Errorf("getting file info: %w", err) + } + return info, nil +} + +func streamWriteToFile(fileData wshrpc.FileData, reader io.Reader) error { + // First truncate the file with an empty write + emptyWrite := fileData + emptyWrite.Data64 = "" + err := wshclient.FileWriteCommand(RpcClient, emptyWrite, &wshrpc.RpcOpts{Timeout: fileTimeout}) + if err != nil { + return fmt.Errorf("initializing file with empty write: %w", err) + } + + const chunkSize = wshrpc.FileChunkSize // 32KB chunks + buf := make([]byte, chunkSize) + totalWritten := int64(0) + + for { + n, err := reader.Read(buf) + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("reading input: %w", err) + } + + // Check total size + totalWritten += int64(n) + if totalWritten > MaxFileSize { + return fmt.Errorf("input exceeds maximum file size of %d bytes", MaxFileSize) + } + + // Prepare and send chunk + chunk := buf[:n] + appendData := fileData + appendData.Data64 = base64.StdEncoding.EncodeToString(chunk) + + err = wshclient.FileAppendCommand(RpcClient, appendData, &wshrpc.RpcOpts{Timeout: int64(fileTimeout)}) + if err != nil { + return fmt.Errorf("appending chunk to file: %w", err) + } + } + + return nil +} + +func streamReadFromFile(ctx context.Context, fileData wshrpc.FileData, writer io.Writer) error { + broker := RpcClient.StreamBroker + if broker == nil { + return fmt.Errorf("stream broker not available") + } + if fileData.Info == nil { + return fmt.Errorf("file info is required") + } + readerRouteId := RpcClientRouteId + if readerRouteId == "" { + return fmt.Errorf("no route id available") + } + conn, err := connparse.ParseURI(fileData.Info.Path) + if err != nil { + return fmt.Errorf("parsing file path: %w", err) + } + writerRouteId := wshutil.MakeConnectionRouteId(conn.Host) + reader, streamMeta := broker.CreateStreamReader(readerRouteId, writerRouteId, 256*1024) + defer reader.Close() + go func() { + <-ctx.Done() + reader.Close() + }() + data := wshrpc.CommandFileStreamData{ + Info: fileData.Info, + StreamMeta: *streamMeta, + } + _, err = wshclient.FileStreamCommand(RpcClient, data, nil) + if err != nil { + return fmt.Errorf("starting file stream: %w", err) + } + _, err = io.Copy(writer, reader) + return err +} + +func fixRelativePaths(path string) (string, error) { + conn, err := connparse.ParseURI(path) + if err != nil { + return "", err + } + if conn.Scheme == connparse.ConnectionTypeWsh { + if conn.Host == connparse.ConnHostCurrent { + conn.Host = RpcContext.Conn + fixedPath, err := fileutil.FixPath(conn.Path) + if err != nil { + return "", err + } + conn.Path = fixedPath + } + if conn.Host == "" { + conn.Host = wshrpc.LocalConnName + } + } + return conn.GetFullURI(), nil +} diff --git a/cmd/wsh/cmd/wshcmd-file.go b/cmd/wsh/cmd/wshcmd-file.go new file mode 100644 index 000000000..a162b4a32 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-file.go @@ -0,0 +1,535 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "bufio" + "bytes" + "encoding/base64" + "fmt" + "io" + "log" + "os" + "strings" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/util/utilfn" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" + "golang.org/x/term" +) + +const ( + MaxFileSize = 10 * 1024 * 1024 // 10MB + + TimeoutYear = int64(365) * 24 * 60 * 60 * 1000 + + UriHelpText = ` + +URI format: [profile]:[uri-scheme]://[connection]/[path] + +Supported URI schemes: + wsh: + Used to access files on remote hosts over SSH via the WSH helper. Allows + for file streaming to Wave and other remotes. + + Profiles are optional for WSH URIs, provided that you have configured the + remote host in your "connections.json" or "~/.ssh/config" file. + + If a profile is provided, it must be defined in "profiles.json" in the Wave + configuration directory. + + Format: wsh://[remote]/[path] + + Shorthands can be used for the current remote and your local computer: + [path] a relative or absolute path on the current remote + //[remote]/[path] a path on a remote + /~/[path] a path relative to the home directory on your local + computer` +) + +var fileCmd = &cobra.Command{ + Use: "file", + Short: "manage files across local and remote systems", + Long: `Manage files across local and remote systems. + +Wave Terminal is capable of managing files from remote SSH hosts and your local +computer. Files are addressed via URIs.` + UriHelpText} + +var fileTimeout int64 + +func init() { + rootCmd.AddCommand(fileCmd) + + fileCmd.PersistentFlags().Int64VarP(&fileTimeout, "timeout", "t", 15000, "timeout in milliseconds for long operations") + + fileListCmd.Flags().BoolP("long", "l", false, "use long listing format") + fileListCmd.Flags().BoolP("one", "1", false, "list one file per line") + fileListCmd.Flags().BoolP("files", "f", false, "list files only") + + fileCmd.AddCommand(fileListCmd) + fileCmd.AddCommand(fileCatCmd) + fileCmd.AddCommand(fileWriteCmd) + fileRmCmd.Flags().BoolP("recursive", "r", false, "remove directories recursively") + fileCmd.AddCommand(fileRmCmd) + fileCmd.AddCommand(fileInfoCmd) + fileCmd.AddCommand(fileAppendCmd) + fileCpCmd.Flags().BoolP("merge", "m", false, "merge directories") + fileCpCmd.Flags().BoolP("force", "f", false, "force overwrite of existing files") + fileCmd.AddCommand(fileCpCmd) + fileMvCmd.Flags().BoolP("force", "f", false, "force overwrite of existing files") + fileCmd.AddCommand(fileMvCmd) +} + +var fileListCmd = &cobra.Command{ + Use: "ls [uri]", + Aliases: []string{"list"}, + Short: "list files", + Long: "List files in a directory. By default, lists files in the current directory." + UriHelpText, + Example: " wsh file ls wsh://user@ec2/home/user/", + RunE: activityWrap("file", fileListRun), + PreRunE: preRunSetupRpcClient, +} + +var fileCatCmd = &cobra.Command{ + Use: "cat [uri]", + Short: "display contents of a file", + Long: "Display the contents of a file." + UriHelpText, + Example: " wsh file cat wsh://user@ec2/home/user/config.txt", + Args: cobra.ExactArgs(1), + RunE: activityWrap("file", fileCatRun), + PreRunE: preRunSetupRpcClient, +} + +var fileInfoCmd = &cobra.Command{ + Use: "info [uri]", + Short: "show wave file information", + Long: "Show information about a file." + UriHelpText, + Example: " wsh file info wsh://user@ec2/home/user/config.txt", + Args: cobra.ExactArgs(1), + RunE: activityWrap("file", fileInfoRun), + PreRunE: preRunSetupRpcClient, +} + +var fileRmCmd = &cobra.Command{ + Use: "rm [uri]", + Short: "remove a file", + Long: "Remove a file." + UriHelpText, + Example: " wsh file rm wsh://user@ec2/home/user/config.txt", + Args: cobra.ExactArgs(1), + RunE: activityWrap("file", fileRmRun), + PreRunE: preRunSetupRpcClient, +} + +var fileWriteCmd = &cobra.Command{ + Use: "write [uri]", + Short: "write stdin into a file (up to 10MB)", + Long: "Write stdin into a file, buffering input (10MB total file size limit)." + UriHelpText, + Example: " echo 'hello' | wsh file write ./greeting.txt", + Args: cobra.ExactArgs(1), + RunE: activityWrap("file", fileWriteRun), + PreRunE: preRunSetupRpcClient, +} + +var fileAppendCmd = &cobra.Command{ + Use: "append [uri]", + Short: "append stdin to a file", + Long: "Append stdin to a file, buffering input (10MB total file size limit)." + UriHelpText, + Example: " tail -f log.txt | wsh file append ./app.log", + Args: cobra.ExactArgs(1), + RunE: activityWrap("file", fileAppendRun), + PreRunE: preRunSetupRpcClient, +} + +var fileCpCmd = &cobra.Command{ + Use: "cp [source-uri] [destination-uri]" + UriHelpText, + Aliases: []string{"copy"}, + Short: "copy files between storage systems, recursively if needed", + Long: "Copy files between different storage systems." + UriHelpText, + Example: " wsh file cp wsh://user@ec2/home/user/config.txt ./local-config.txt\n wsh file cp ./local-config.txt wsh://user@ec2/home/user/config.txt", + Args: cobra.ExactArgs(2), + RunE: activityWrap("file", fileCpRun), + PreRunE: preRunSetupRpcClient, +} + +var fileMvCmd = &cobra.Command{ + Use: "mv [source-uri] [destination-uri]" + UriHelpText, + Aliases: []string{"move"}, + Short: "move files between storage systems", + Long: "Move files between different storage systems. The source file will be deleted once the operation completes successfully." + UriHelpText, + Example: " wsh file mv wsh://user@ec2/home/user/config.txt ./local-config.txt\n wsh file mv ./local-config.txt wsh://user@ec2/home/user/config.txt", + Args: cobra.ExactArgs(2), + RunE: activityWrap("file", fileMvRun), + PreRunE: preRunSetupRpcClient, +} + +func fileCatRun(cmd *cobra.Command, args []string) error { + path, err := fixRelativePaths(args[0]) + if err != nil { + return err + } + + fileData := wshrpc.FileData{ + Info: &wshrpc.FileInfo{ + Path: path}} + + err = streamReadFromFile(cmd.Context(), fileData, os.Stdout) + if err != nil { + return fmt.Errorf("reading file: %w", err) + } + + return nil +} + +func fileInfoRun(cmd *cobra.Command, args []string) error { + path, err := fixRelativePaths(args[0]) + if err != nil { + return err + } + fileData := wshrpc.FileData{ + Info: &wshrpc.FileInfo{ + Path: path}} + + info, err := wshclient.FileInfoCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout}) + err = convertNotFoundErr(err) + if err != nil { + return fmt.Errorf("getting file info: %w", err) + } + + if info.NotFound { + return fmt.Errorf("%s: no such file", path) + } + + WriteStdout("name:\t%s\n", info.Name) + if info.Mode != 0 { + WriteStdout("mode:\t%s\n", info.Mode.String()) + } + WriteStdout("mtime:\t%s\n", time.Unix(info.ModTime/1000, 0).Format(time.DateTime)) + if !info.IsDir { + WriteStdout("size:\t%d\n", info.Size) + } + if info.Meta != nil && len(*info.Meta) > 0 { + WriteStdout("metadata:\n") + for k, v := range *info.Meta { + WriteStdout("\t\t\t%s: %v\n", k, v) + } + } + return nil +} + +func fileRmRun(cmd *cobra.Command, args []string) error { + path, err := fixRelativePaths(args[0]) + if err != nil { + return err + } + recursive, err := cmd.Flags().GetBool("recursive") + if err != nil { + return err + } + + err = wshclient.FileDeleteCommand(RpcClient, wshrpc.CommandDeleteFileData{Path: path, Recursive: recursive}, &wshrpc.RpcOpts{Timeout: fileTimeout}) + if err != nil { + return fmt.Errorf("removing file: %w", err) + } + + return nil +} + +func fileWriteRun(cmd *cobra.Command, args []string) error { + path, err := fixRelativePaths(args[0]) + if err != nil { + return err + } + fileData := wshrpc.FileData{ + Info: &wshrpc.FileInfo{ + Path: path}} + + limitReader := io.LimitReader(WrappedStdin, MaxFileSize+1) + data, err := io.ReadAll(limitReader) + if err != nil { + return fmt.Errorf("reading input: %w", err) + } + if len(data) > MaxFileSize { + return fmt.Errorf("input exceeds maximum file size of %d bytes", MaxFileSize) + } + fileData.Data64 = base64.StdEncoding.EncodeToString(data) + err = wshclient.FileWriteCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout}) + if err != nil { + return fmt.Errorf("writing file: %w", err) + } + + return nil +} + +func fileAppendRun(cmd *cobra.Command, args []string) error { + path, err := fixRelativePaths(args[0]) + if err != nil { + return err + } + fileData := wshrpc.FileData{ + Info: &wshrpc.FileInfo{ + Path: path}} + + info, err := ensureFile(fileData) + if err != nil { + return err + } + if info.Size >= MaxFileSize { + return fmt.Errorf("file already at maximum size (%d bytes)", MaxFileSize) + } + + reader := bufio.NewReader(WrappedStdin) + var buf bytes.Buffer + remainingSpace := MaxFileSize - info.Size + for { + chunk := make([]byte, 8192) + n, err := reader.Read(chunk) + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("reading input: %w", err) + } + + if int64(buf.Len()+n) > remainingSpace { + return fmt.Errorf("append would exceed maximum file size of %d bytes", MaxFileSize) + } + + buf.Write(chunk[:n]) + + if buf.Len() >= 8192 { // 8KB batch size + fileData.Data64 = base64.StdEncoding.EncodeToString(buf.Bytes()) + err = wshclient.FileAppendCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout}) + if err != nil { + return fmt.Errorf("appending to file: %w", err) + } + remainingSpace -= int64(buf.Len()) + buf.Reset() + } + } + + if buf.Len() > 0 { + fileData.Data64 = base64.StdEncoding.EncodeToString(buf.Bytes()) + err = wshclient.FileAppendCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout}) + if err != nil { + return fmt.Errorf("appending to file: %w", err) + } + } + + return nil +} + +func checkFileSize(path string, maxSize int64) (*wshrpc.FileInfo, error) { + fileData := wshrpc.FileData{ + Info: &wshrpc.FileInfo{ + Path: path}} + + info, err := wshclient.FileInfoCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout}) + err = convertNotFoundErr(err) + if err != nil { + return nil, fmt.Errorf("getting file info: %w", err) + } + if info.NotFound { + return nil, fmt.Errorf("%s: no such file", path) + } + if info.IsDir { + return nil, fmt.Errorf("%s: is a directory", path) + } + if info.Size > maxSize { + return nil, fmt.Errorf("file size (%d bytes) exceeds maximum of %d bytes", info.Size, maxSize) + } + return info, nil +} + +func fileCpRun(cmd *cobra.Command, args []string) error { + src, dst := args[0], args[1] + merge, err := cmd.Flags().GetBool("merge") + if err != nil { + return err + } + force, err := cmd.Flags().GetBool("force") + if err != nil { + return err + } + + srcPath, err := fixRelativePaths(src) + if err != nil { + return fmt.Errorf("unable to parse src path: %w", err) + } + + _, err = checkFileSize(srcPath, MaxFileSize) + if err != nil { + return err + } + + destPath, err := fixRelativePaths(dst) + if err != nil { + return fmt.Errorf("unable to parse dest path: %w", err) + } + log.Printf("Copying %s to %s; merge: %v, force: %v", srcPath, destPath, merge, force) + rpcOpts := &wshrpc.RpcOpts{Timeout: TimeoutYear} + err = wshclient.FileCopyCommand(RpcClient, wshrpc.CommandFileCopyData{SrcUri: srcPath, DestUri: destPath, Opts: &wshrpc.FileCopyOpts{Merge: merge, Overwrite: force, Timeout: TimeoutYear}}, rpcOpts) + if err != nil { + return fmt.Errorf("copying file: %w", err) + } + return nil +} + +func fileMvRun(cmd *cobra.Command, args []string) error { + src, dst := args[0], args[1] + force, err := cmd.Flags().GetBool("force") + if err != nil { + return err + } + + srcPath, err := fixRelativePaths(src) + if err != nil { + return fmt.Errorf("unable to parse src path: %w", err) + } + + _, err = checkFileSize(srcPath, MaxFileSize) + if err != nil { + return err + } + + destPath, err := fixRelativePaths(dst) + if err != nil { + return fmt.Errorf("unable to parse dest path: %w", err) + } + log.Printf("Moving %s to %s; force: %v", srcPath, destPath, force) + rpcOpts := &wshrpc.RpcOpts{Timeout: TimeoutYear} + err = wshclient.FileMoveCommand(RpcClient, wshrpc.CommandFileCopyData{SrcUri: srcPath, DestUri: destPath, Opts: &wshrpc.FileCopyOpts{Overwrite: force, Timeout: TimeoutYear}}, rpcOpts) + if err != nil { + return fmt.Errorf("moving file: %w", err) + } + return nil +} + +func filePrintColumns(filesChan <-chan wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData]) error { + width := 80 + w, _, err := term.GetSize(int(os.Stdout.Fd())) + if err == nil { + width = w + } + + var allNames []string + maxLen := 0 + for respUnion := range filesChan { + if respUnion.Error != nil { + return respUnion.Error + } + for _, f := range respUnion.Response.FileInfo { + allNames = append(allNames, f.Name) + if len(f.Name) > maxLen { + maxLen = len(f.Name) + } + } + } + + colWidth := maxLen + 2 + numCols := width / colWidth + if numCols < 1 { + numCols = 1 + } + + col := 0 + for _, name := range allNames { + fmt.Fprintf(os.Stdout, "%-*s", colWidth, name) + col++ + if col >= numCols { + fmt.Fprintln(os.Stdout) + col = 0 + } + } + if col > 0 { + fmt.Fprintln(os.Stdout) + } + + return nil +} + +func filePrintLong(filesChan <-chan wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData]) error { + var allFiles []*wshrpc.FileInfo + + for respUnion := range filesChan { + if respUnion.Error != nil { + return respUnion.Error + } + resp := respUnion.Response + allFiles = append(allFiles, resp.FileInfo...) + } + + maxNameLen := 0 + for _, fi := range allFiles { + if len(fi.Name) > maxNameLen { + maxNameLen = len(fi.Name) + } + } + + nameWidth := maxNameLen + 2 + if nameWidth > 60 { + nameWidth = 60 + } + + writer := tabwriter.NewWriter(os.Stdout, 0, 8, 1, '\t', 0) + + for _, f := range allFiles { + name := f.Name + t := time.Unix(f.ModTime/1000, 0) + timestamp := utilfn.FormatLsTime(t) + if f.Size == 0 && strings.HasSuffix(name, "/") { + fmt.Fprintf(writer, "%-*s\t%8s\t%s\n", nameWidth, name, "-", timestamp) + } else { + fmt.Fprintf(writer, "%-*s\t%8d\t%s\n", nameWidth, name, f.Size, timestamp) + } + } + writer.Flush() + + return nil +} + +func fileListRun(cmd *cobra.Command, args []string) error { + longForm, _ := cmd.Flags().GetBool("long") + onePerLine, _ := cmd.Flags().GetBool("one") + + // Check if we're in a pipe + stat, _ := os.Stdout.Stat() + isPipe := (stat.Mode() & os.ModeCharDevice) == 0 + if isPipe { + onePerLine = true + } + + if len(args) == 0 { + args = []string{"."} + } + + path, err := fixRelativePaths(args[0]) + if err != nil { + return err + } + + filesChan := wshclient.FileListStreamCommand(RpcClient, wshrpc.FileListData{Path: path, Opts: &wshrpc.FileListOpts{All: false}}, &wshrpc.RpcOpts{Timeout: 2000}) + // Drain the channel when done + defer utilfn.DrainChannelSafe(filesChan, "fileListRun") + if longForm { + return filePrintLong(filesChan) + } + + if onePerLine { + for respUnion := range filesChan { + if respUnion.Error != nil { + log.Printf("error: %v", respUnion.Error) + return respUnion.Error + } + for _, f := range respUnion.Response.FileInfo { + fmt.Fprintln(os.Stdout, f.Name) + } + } + return nil + } + + return filePrintColumns(filesChan) +} diff --git a/cmd/wsh/cmd/wshcmd-focusblock.go b/cmd/wsh/cmd/wshcmd-focusblock.go new file mode 100644 index 000000000..a414bf960 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-focusblock.go @@ -0,0 +1,51 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +var focusBlockCmd = &cobra.Command{ + Use: "focusblock [-b {blockid|blocknum|this}]", + Short: "focus a block in the current tab", + Args: cobra.NoArgs, + RunE: focusBlockRun, + PreRunE: preRunSetupRpcClient, +} + +func init() { + rootCmd.AddCommand(focusBlockCmd) +} + +func focusBlockRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("focusblock", rtnErr == nil) + }() + + tabId := os.Getenv("WAVETERM_TABID") + if tabId == "" { + return fmt.Errorf("no tab id specified (set WAVETERM_TABID environment variable)") + } + + fullORef, err := resolveBlockArg() + if err != nil { + return err + } + + route := fmt.Sprintf("tab:%s", tabId) + err = wshclient.SetBlockFocusCommand(RpcClient, fullORef.OID, &wshrpc.RpcOpts{ + Route: route, + Timeout: 2000, + }) + if err != nil { + return fmt.Errorf("focusing block: %v", err) + } + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-getmeta.go b/cmd/wsh/cmd/wshcmd-getmeta.go new file mode 100644 index 000000000..d668d0937 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-getmeta.go @@ -0,0 +1,120 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +var getMetaCmd = &cobra.Command{ + Use: "getmeta [key...]", + Short: "get metadata for an entity", + Long: "Get metadata for an entity. Keys can be exact matches or patterns like 'name:*' to get all keys that start with 'name:'", + Args: cobra.ArbitraryArgs, + RunE: getMetaRun, + PreRunE: preRunSetupRpcClient, +} + +var getMetaRawOutput bool +var getMetaClearPrefix bool +var getMetaVerbose bool + +func init() { + rootCmd.AddCommand(getMetaCmd) + getMetaCmd.Flags().BoolVarP(&getMetaVerbose, "verbose", "v", false, "output full metadata") + getMetaCmd.Flags().BoolVar(&getMetaRawOutput, "raw", false, "output singleton string values without quotes") + getMetaCmd.Flags().BoolVar(&getMetaClearPrefix, "clear-prefix", false, "output the special clearing key for prefix queries") +} + +func filterMetaKeys(meta map[string]interface{}, keys []string) map[string]interface{} { + result := make(map[string]interface{}) + + // Process each requested key + for _, key := range keys { + if strings.HasSuffix(key, ":*") { + // Handle pattern matching + prefix := strings.TrimSuffix(key, "*") + baseKey := strings.TrimSuffix(prefix, ":") + + if getMetaClearPrefix { + result[key] = true + } + + // Include the base key without colon if it exists + if val, exists := meta[baseKey]; exists { + result[baseKey] = val + } + + // Include all keys with the prefix + for k, v := range meta { + if strings.HasPrefix(k, prefix) { + result[k] = v + } + } + } else { + // Handle exact key match + if val, exists := meta[key]; exists { + result[key] = val + } else { + result[key] = nil + } + } + } + + return result +} + +func getMetaRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("getmeta", rtnErr == nil) + }() + fullORef, err := resolveBlockArg() + if err != nil { + return err + } + if getMetaVerbose { + fmt.Fprintf(os.Stderr, "resolved-id: %s\n", fullORef.String()) + } + resp, err := wshclient.GetMetaCommand(RpcClient, wshrpc.CommandGetMetaData{ORef: *fullORef}, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("getting metadata: %w", err) + } + + var output interface{} + if len(args) > 0 { + if len(args) == 1 && !strings.HasSuffix(args[0], ":*") { + // Single key case - output just the value + output = resp[args[0]] + } else { + // Multiple keys or pattern matching case - output object + output = filterMetaKeys(resp, args) + } + } else { + // No args case - output full metadata + output = resp + } + + // Handle raw string output + if getMetaRawOutput { + if str, ok := output.(string); ok { + WriteStdout("%s\n", str) + return + } + } + + outBArr, err := json.MarshalIndent(output, "", " ") + if err != nil { + return fmt.Errorf("formatting metadata: %w", err) + } + outStr := string(outBArr) + WriteStdout("%s\n", outStr) + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-getvar.go b/cmd/wsh/cmd/wshcmd-getvar.go new file mode 100644 index 000000000..4ed3274e4 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-getvar.go @@ -0,0 +1,131 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +var getVarCmd = &cobra.Command{ + Use: "getvar [flags] [key]", + Short: "get variable(s) from a block", + Long: `Get variable(s) from a block. Without --all, requires a key argument. +With --all, prints all variables. Use -0 for null-terminated output.`, + Example: " wsh getvar FOO\n wsh getvar --all\n wsh getvar --all -0", + RunE: getVarRun, + PreRunE: preRunSetupRpcClient, +} + +var ( + getVarFileName string + getVarAllVars bool + getVarNullTerminate bool + getVarLocal bool + getVarFlagNL bool + getVarFlagNoNL bool +) + +func init() { + rootCmd.AddCommand(getVarCmd) + getVarCmd.Flags().StringVar(&getVarFileName, "varfile", DefaultVarFileName, "var file name") + getVarCmd.Flags().BoolVar(&getVarAllVars, "all", false, "get all variables") + getVarCmd.Flags().BoolVarP(&getVarNullTerminate, "null", "0", false, "use null terminators in output") + getVarCmd.Flags().BoolVarP(&getVarLocal, "local", "l", false, "get variables local to block") + getVarCmd.Flags().BoolVarP(&getVarFlagNL, "newline", "n", false, "print newline after output") + getVarCmd.Flags().BoolVarP(&getVarFlagNoNL, "no-newline", "N", false, "do not print newline after output") +} + +func shouldPrintNewline() bool { + isTty := getIsTty() + if getVarFlagNL { + return true + } + if getVarFlagNoNL { + return false + } + return isTty +} + +func getVarRun(cmd *cobra.Command, args []string) error { + defer func() { + sendActivity("getvar", WshExitCode == 0) + }() + + // Resolve block to get zoneId + if blockArg == "" { + if getVarLocal { + blockArg = "this" + } else { + blockArg = "client" + } + } + fullORef, err := resolveBlockArg() + if err != nil { + return err + } + + if getVarAllVars { + if len(args) > 0 { + return fmt.Errorf("cannot specify key with --all") + } + return getAllVariables(fullORef.OID) + } + + // Single variable case - existing logic + if len(args) != 1 { + OutputHelpMessage(cmd) + return fmt.Errorf("requires a key argument") + } + + key := args[0] + commandData := wshrpc.CommandVarData{ + Key: key, + ZoneId: fullORef.OID, + FileName: getVarFileName, + } + + resp, err := wshclient.GetVarCommand(RpcClient, commandData, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("getting variable: %w", err) + } + + if !resp.Exists { + WshExitCode = 1 + return nil + } + + WriteStdout("%s", resp.Val) + if shouldPrintNewline() { + WriteStdout("\n") + } + + return nil +} + +func getAllVariables(zoneId string) error { + commandData := wshrpc.CommandVarData{ + ZoneId: zoneId, + FileName: getVarFileName, + } + + vars, err := wshclient.GetAllVarsCommand(RpcClient, commandData, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("getting variables: %w", err) + } + + terminator := "\n" + if getVarNullTerminate { + terminator = "\x00" + } + + for _, v := range vars { + WriteStdout("%s=%s%s", v.Key, v.Val, terminator) + } + + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-jobdebug.go b/cmd/wsh/cmd/wshcmd-jobdebug.go new file mode 100644 index 000000000..6fc6f0f15 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-jobdebug.go @@ -0,0 +1,448 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "os" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" + "github.com/s-zx/crest/pkg/wshutil" +) + +var jobDebugCmd = &cobra.Command{ + Use: "jobdebug", + Short: "debugging commands for the job system", + Hidden: true, + PersistentPreRunE: preRunSetupRpcClient, +} + +var jobDebugListCmd = &cobra.Command{ + Use: "list", + Short: "list all jobs with debug information", + RunE: jobDebugListRun, +} + +var jobDebugDeleteCmd = &cobra.Command{ + Use: "delete", + Short: "delete a job entry by jobid", + RunE: jobDebugDeleteRun, +} + +var jobDebugDeleteAllCmd = &cobra.Command{ + Use: "deleteall", + Short: "delete all jobs", + RunE: jobDebugDeleteAllRun, +} + +var jobDebugPruneCmd = &cobra.Command{ + Use: "prune", + Short: "remove jobs where the job manager is no longer running", + RunE: jobDebugPruneRun, +} + +var jobDebugTerminateCmd = &cobra.Command{ + Use: "terminate", + Short: "terminate a job manager", + RunE: jobDebugTerminateRun, +} + +var jobDebugDisconnectCmd = &cobra.Command{ + Use: "disconnect", + Short: "disconnect from a job manager", + RunE: jobDebugDisconnectRun, +} + +var jobDebugReconnectCmd = &cobra.Command{ + Use: "reconnect", + Short: "reconnect to a job manager", + RunE: jobDebugReconnectRun, +} + +var jobDebugReconnectConnCmd = &cobra.Command{ + Use: "reconnectconn", + Short: "reconnect all jobs for a connection", + RunE: jobDebugReconnectConnRun, +} + +var jobDebugGetOutputCmd = &cobra.Command{ + Use: "getoutput", + Short: "get the terminal output for a job", + RunE: jobDebugGetOutputRun, +} + +var jobDebugStartCmd = &cobra.Command{ + Use: "start", + Short: "start a new job", + Args: cobra.MinimumNArgs(1), + RunE: jobDebugStartRun, +} + +var jobDebugAttachJobCmd = &cobra.Command{ + Use: "attachjob", + Short: "attach a job to a block", + RunE: jobDebugAttachJobRun, +} + +var jobDebugDetachJobCmd = &cobra.Command{ + Use: "detachjob", + Short: "detach a job from its block", + RunE: jobDebugDetachJobRun, +} + +var jobDebugBlockAttachmentCmd = &cobra.Command{ + Use: "blockattachment", + Short: "show the attached job for a block", + RunE: jobDebugBlockAttachmentRun, +} + +var jobIdFlag string +var jobDebugJsonFlag bool +var jobConnFlag string +var terminateJobIdFlag string +var disconnectJobIdFlag string +var reconnectJobIdFlag string +var reconnectConnNameFlag string +var attachJobIdFlag string +var attachBlockIdFlag string +var detachJobIdFlag string + +func init() { + rootCmd.AddCommand(jobDebugCmd) + jobDebugCmd.AddCommand(jobDebugListCmd) + jobDebugCmd.AddCommand(jobDebugDeleteCmd) + jobDebugCmd.AddCommand(jobDebugDeleteAllCmd) + jobDebugCmd.AddCommand(jobDebugPruneCmd) + jobDebugCmd.AddCommand(jobDebugTerminateCmd) + jobDebugCmd.AddCommand(jobDebugDisconnectCmd) + jobDebugCmd.AddCommand(jobDebugReconnectCmd) + jobDebugCmd.AddCommand(jobDebugReconnectConnCmd) + jobDebugCmd.AddCommand(jobDebugGetOutputCmd) + jobDebugCmd.AddCommand(jobDebugStartCmd) + jobDebugCmd.AddCommand(jobDebugAttachJobCmd) + jobDebugCmd.AddCommand(jobDebugDetachJobCmd) + jobDebugCmd.AddCommand(jobDebugBlockAttachmentCmd) + + jobDebugListCmd.Flags().BoolVar(&jobDebugJsonFlag, "json", false, "output as JSON") + + jobDebugDeleteCmd.Flags().StringVar(&jobIdFlag, "jobid", "", "job id to delete (required)") + jobDebugDeleteCmd.MarkFlagRequired("jobid") + + jobDebugTerminateCmd.Flags().StringVar(&terminateJobIdFlag, "jobid", "", "job id to terminate (required)") + jobDebugTerminateCmd.MarkFlagRequired("jobid") + + jobDebugDisconnectCmd.Flags().StringVar(&disconnectJobIdFlag, "jobid", "", "job id to disconnect (required)") + jobDebugDisconnectCmd.MarkFlagRequired("jobid") + + jobDebugReconnectCmd.Flags().StringVar(&reconnectJobIdFlag, "jobid", "", "job id to reconnect (required)") + jobDebugReconnectCmd.MarkFlagRequired("jobid") + + jobDebugReconnectConnCmd.Flags().StringVar(&reconnectConnNameFlag, "conn", "", "connection name (required)") + jobDebugReconnectConnCmd.MarkFlagRequired("conn") + + jobDebugGetOutputCmd.Flags().StringVar(&jobIdFlag, "jobid", "", "job id to get output for (required)") + jobDebugGetOutputCmd.MarkFlagRequired("jobid") + + jobDebugStartCmd.Flags().StringVar(&jobConnFlag, "conn", "", "connection name (required)") + jobDebugStartCmd.MarkFlagRequired("conn") + + jobDebugAttachJobCmd.Flags().StringVar(&attachJobIdFlag, "jobid", "", "job id to attach (required)") + jobDebugAttachJobCmd.MarkFlagRequired("jobid") + jobDebugAttachJobCmd.Flags().StringVar(&attachBlockIdFlag, "blockid", "", "block id to attach to (required)") + jobDebugAttachJobCmd.MarkFlagRequired("blockid") + + jobDebugDetachJobCmd.Flags().StringVar(&detachJobIdFlag, "jobid", "", "job id to detach (required)") + jobDebugDetachJobCmd.MarkFlagRequired("jobid") +} + +func jobDebugListRun(cmd *cobra.Command, args []string) error { + rtnData, err := wshclient.JobControllerListCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 5000}) + if err != nil { + return fmt.Errorf("getting job debug list: %w", err) + } + + connectedJobIds, err := wshclient.JobControllerConnectedJobsCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 5000}) + if err != nil { + return fmt.Errorf("getting connected job ids: %w", err) + } + + connectedMap := make(map[string]bool) + for _, jobId := range connectedJobIds { + connectedMap[jobId] = true + } + + if jobDebugJsonFlag { + jsonData, err := json.MarshalIndent(rtnData, "", " ") + if err != nil { + return fmt.Errorf("marshaling json: %w", err) + } + fmt.Printf("%s\n", string(jsonData)) + return nil + } + + fmt.Printf("%-36s %-25s %-9s %-10s %-6s %-30s %-8s %-10s %-8s\n", "OID", "Connection", "Connected", "Manager", "Reason", "Cmd", "ExitCode", "Stream", "Attached") + for _, job := range rtnData { + connectedStatus := "no" + if connectedMap[job.OID] { + connectedStatus = "yes" + } + if job.TerminateOnReconnect { + connectedStatus += "*" + } + + streamStatus := "-" + if job.StreamDone { + if job.StreamError == "" { + streamStatus = "EOF" + } else { + streamStatus = fmt.Sprintf("%q", job.StreamError) + } + } + + exitCode := "-" + if job.CmdExitTs > 0 { + if job.CmdExitCode != nil { + exitCode = fmt.Sprintf("%d", *job.CmdExitCode) + } else if job.CmdExitSignal != "" { + exitCode = job.CmdExitSignal + } else { + exitCode = "?" + } + } + + doneReason := "-" + if job.JobManagerDoneReason == "startuperror" { + doneReason = "serr" + } else if job.JobManagerDoneReason == "gone" { + doneReason = "gone" + } else if job.JobManagerDoneReason == "terminated" { + doneReason = "term" + } + + attachedBlock := "-" + if job.AttachedBlockId != "" { + if len(job.AttachedBlockId) >= 8 { + attachedBlock = job.AttachedBlockId[:8] + } else { + attachedBlock = job.AttachedBlockId + } + } + + fmt.Printf("%-36s %-25s %-9s %-10s %-6s %-30s %-8s %-10s %-8s\n", + job.OID, job.Connection, connectedStatus, job.JobManagerStatus, doneReason, job.Cmd, exitCode, streamStatus, attachedBlock) + } + return nil +} + +func jobDebugDeleteRun(cmd *cobra.Command, args []string) error { + err := wshclient.JobControllerDeleteJobCommand(RpcClient, jobIdFlag, &wshrpc.RpcOpts{Timeout: 5000}) + if err != nil { + return fmt.Errorf("deleting job: %w", err) + } + + fmt.Printf("Job %s deleted successfully\n", jobIdFlag) + return nil +} + +func jobDebugDeleteAllRun(cmd *cobra.Command, args []string) error { + rtnData, err := wshclient.JobControllerListCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 5000}) + if err != nil { + return fmt.Errorf("getting job debug list: %w", err) + } + + if len(rtnData) == 0 { + fmt.Printf("No jobs to delete\n") + return nil + } + + deletedCount := 0 + for _, job := range rtnData { + err := wshclient.JobControllerDeleteJobCommand(RpcClient, job.OID, &wshrpc.RpcOpts{Timeout: 5000}) + if err != nil { + fmt.Printf("Error deleting job %s: %v\n", job.OID, err) + } else { + deletedCount++ + } + } + + fmt.Printf("Deleted %d of %d job(s)\n", deletedCount, len(rtnData)) + return nil +} + +func jobDebugPruneRun(cmd *cobra.Command, args []string) error { + rtnData, err := wshclient.JobControllerListCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 5000}) + if err != nil { + return fmt.Errorf("getting job debug list: %w", err) + } + + if len(rtnData) == 0 { + fmt.Printf("No jobs to prune\n") + return nil + } + + deletedCount := 0 + for _, job := range rtnData { + if job.JobManagerStatus != "running" { + err := wshclient.JobControllerDeleteJobCommand(RpcClient, job.OID, &wshrpc.RpcOpts{Timeout: 5000}) + if err != nil { + fmt.Printf("Error deleting job %s: %v\n", job.OID, err) + } else { + deletedCount++ + } + } + } + + if deletedCount == 0 { + fmt.Printf("No jobs with stopped job managers to prune\n") + } else { + fmt.Printf("Pruned %d job(s) with stopped job managers\n", deletedCount) + } + return nil +} + +func jobDebugTerminateRun(cmd *cobra.Command, args []string) error { + err := wshclient.JobControllerExitJobCommand(RpcClient, terminateJobIdFlag, nil) + if err != nil { + return fmt.Errorf("terminating job manager: %w", err) + } + + fmt.Printf("Job manager for %s terminated successfully\n", terminateJobIdFlag) + return nil +} + +func jobDebugDisconnectRun(cmd *cobra.Command, args []string) error { + err := wshclient.JobControllerDisconnectJobCommand(RpcClient, disconnectJobIdFlag, nil) + if err != nil { + return fmt.Errorf("disconnecting from job manager: %w", err) + } + + fmt.Printf("Disconnected from job manager for %s successfully\n", disconnectJobIdFlag) + return nil +} + +func jobDebugReconnectRun(cmd *cobra.Command, args []string) error { + err := wshclient.JobControllerReconnectJobCommand(RpcClient, reconnectJobIdFlag, nil) + if err != nil { + return fmt.Errorf("reconnecting to job manager: %w", err) + } + + fmt.Printf("Reconnected to job manager for %s successfully\n", reconnectJobIdFlag) + return nil +} + +func jobDebugReconnectConnRun(cmd *cobra.Command, args []string) error { + err := wshclient.JobControllerReconnectJobsForConnCommand(RpcClient, reconnectConnNameFlag, nil) + if err != nil { + return fmt.Errorf("reconnecting jobs for connection: %w", err) + } + + fmt.Printf("Reconnected all jobs for connection %s successfully\n", reconnectConnNameFlag) + return nil +} + +func jobDebugGetOutputRun(cmd *cobra.Command, args []string) error { + broker := RpcClient.StreamBroker + if broker == nil { + return fmt.Errorf("stream broker not available") + } + + readerRouteId, err := wshclient.ControlGetRouteIdCommand(RpcClient, &wshrpc.RpcOpts{Route: wshutil.ControlRoute}) + if err != nil { + return fmt.Errorf("getting route id: %w", err) + } + if readerRouteId == "" { + return fmt.Errorf("no route to receive data") + } + writerRouteId := "" // main server route + reader, streamMeta := broker.CreateStreamReader(readerRouteId, writerRouteId, 64*1024) + defer reader.Close() + + data := wshrpc.CommandWaveFileReadStreamData{ + ZoneId: jobIdFlag, + Name: "term", + StreamMeta: *streamMeta, + } + + _, err = wshclient.WaveFileReadStreamCommand(RpcClient, data, nil) + if err != nil { + return fmt.Errorf("starting stream read: %w", err) + } + + _, err = io.Copy(os.Stdout, reader) + if err != nil { + return fmt.Errorf("reading stream: %w", err) + } + return nil +} + +func jobDebugStartRun(cmd *cobra.Command, args []string) error { + cmdToRun := args[0] + cmdArgs := args[1:] + + data := wshrpc.CommandJobControllerStartJobData{ + ConnName: jobConnFlag, + JobKind: "task", + Cmd: cmdToRun, + Args: cmdArgs, + Env: make(map[string]string), + TermSize: nil, + } + + jobId, err := wshclient.JobControllerStartJobCommand(RpcClient, data, &wshrpc.RpcOpts{Timeout: 10000}) + if err != nil { + return fmt.Errorf("starting job: %w", err) + } + + fmt.Printf("Job started successfully with ID: %s\n", jobId) + return nil +} + +func jobDebugAttachJobRun(cmd *cobra.Command, args []string) error { + data := wshrpc.CommandJobControllerAttachJobData{ + JobId: attachJobIdFlag, + BlockId: attachBlockIdFlag, + } + + err := wshclient.JobControllerAttachJobCommand(RpcClient, data, &wshrpc.RpcOpts{Timeout: 5000}) + if err != nil { + return fmt.Errorf("attaching job: %w", err) + } + + fmt.Printf("Job %s attached to block %s successfully\n", attachJobIdFlag, attachBlockIdFlag) + return nil +} + +func jobDebugDetachJobRun(cmd *cobra.Command, args []string) error { + err := wshclient.JobControllerDetachJobCommand(RpcClient, detachJobIdFlag, &wshrpc.RpcOpts{Timeout: 5000}) + if err != nil { + return fmt.Errorf("detaching job: %w", err) + } + + fmt.Printf("Job %s detached successfully\n", detachJobIdFlag) + return nil +} + +func jobDebugBlockAttachmentRun(cmd *cobra.Command, args []string) error { + blockORef, err := resolveBlockArg() + if err != nil { + return err + } + + blockId := blockORef.OID + jobStatus, err := wshclient.BlockJobStatusCommand(RpcClient, blockId, &wshrpc.RpcOpts{Timeout: 5000}) + if err != nil { + return fmt.Errorf("getting block job status: %w", err) + } + + if jobStatus.JobId == "" { + fmt.Printf("Block %s: no attached job\n", blockId) + } else { + fmt.Printf("Block %s: attached to job %s\n", blockId, jobStatus.JobId) + } + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-jobmanager.go b/cmd/wsh/cmd/wshcmd-jobmanager.go new file mode 100644 index 000000000..feb70fd17 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-jobmanager.go @@ -0,0 +1,119 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "bufio" + "context" + "encoding/base64" + "fmt" + "os" + "strings" + "time" + + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/jobmanager" +) + +var jobManagerCmd = &cobra.Command{ + Use: "jobmanager", + Hidden: true, + Short: "job manager for wave terminal", + Args: cobra.NoArgs, + RunE: jobManagerRun, +} + +var jobManagerJobId string +var jobManagerClientId string + +func init() { + jobManagerCmd.Flags().StringVar(&jobManagerJobId, "jobid", "", "job ID (UUID, required)") + jobManagerCmd.Flags().StringVar(&jobManagerClientId, "clientid", "", "client ID (UUID, required)") + jobManagerCmd.MarkFlagRequired("jobid") + jobManagerCmd.MarkFlagRequired("clientid") + rootCmd.AddCommand(jobManagerCmd) +} + +func jobManagerRun(cmd *cobra.Command, args []string) error { + _, err := uuid.Parse(jobManagerJobId) + if err != nil { + return fmt.Errorf("invalid jobid: must be a valid UUID") + } + + _, err = uuid.Parse(jobManagerClientId) + if err != nil { + return fmt.Errorf("invalid clientid: must be a valid UUID") + } + + publicKeyB64 := os.Getenv("WAVETERM_PUBLICKEY") + if publicKeyB64 == "" { + return fmt.Errorf("WAVETERM_PUBLICKEY environment variable is not set") + } + + publicKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyB64) + if err != nil { + return fmt.Errorf("failed to decode WAVETERM_PUBLICKEY: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + jobAuthToken, err := readJobAuthToken(ctx) + if err != nil { + return fmt.Errorf("failed to read job auth token: %v", err) + } + + readyFile := os.NewFile(3, "ready-pipe") + _, err = readyFile.Stat() + if err != nil { + return fmt.Errorf("ready pipe (fd 3) not available: %v", err) + } + + err = jobmanager.SetupJobManager(jobManagerClientId, jobManagerJobId, publicKeyBytes, jobAuthToken, readyFile) + if err != nil { + return fmt.Errorf("error setting up job manager: %v", err) + } + + select {} +} + +func readJobAuthToken(ctx context.Context) (string, error) { + resultCh := make(chan string, 1) + errorCh := make(chan error, 1) + + go func() { + reader := bufio.NewReader(os.Stdin) + line, err := reader.ReadString('\n') + if err != nil { + errorCh <- fmt.Errorf("error reading from stdin: %v", err) + return + } + + line = strings.TrimSpace(line) + prefix := jobmanager.JobAccessTokenLabel + ":" + if !strings.HasPrefix(line, prefix) { + errorCh <- fmt.Errorf("invalid token format: expected '%s'", prefix) + return + } + + token := strings.TrimPrefix(line, prefix) + token = strings.TrimSpace(token) + if token == "" { + errorCh <- fmt.Errorf("empty job auth token") + return + } + + resultCh <- token + }() + + select { + case token := <-resultCh: + return token, nil + case err := <-errorCh: + return "", err + case <-ctx.Done(): + return "", ctx.Err() + } +} diff --git a/cmd/wsh/cmd/wshcmd-launch.go b/cmd/wsh/cmd/wshcmd-launch.go new file mode 100644 index 000000000..643488148 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-launch.go @@ -0,0 +1,72 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +var magnifyBlock bool + +var launchCmd = &cobra.Command{ + Use: "launch", + Short: "launch a widget by its ID", + Args: cobra.ExactArgs(1), + RunE: launchRun, + PreRunE: preRunSetupRpcClient, +} + +func init() { + launchCmd.Flags().BoolVarP(&magnifyBlock, "magnify", "m", false, "start the widget in magnified mode") + rootCmd.AddCommand(launchCmd) +} + +func launchRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("launch", rtnErr == nil) + }() + + widgetId := args[0] + + // Get the full configuration + config, err := wshclient.GetFullConfigCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("getting configuration: %w", err) + } + + // Look for widget in both widgets and defaultwidgets + widget, ok := config.Widgets[widgetId] + if !ok { + widget, ok = config.DefaultWidgets[widgetId] + if !ok { + return fmt.Errorf("widget %q not found in configuration", widgetId) + } + } + + tabId := getTabIdFromEnv() + if tabId == "" { + return fmt.Errorf("no WAVETERM_TABID env var set") + } + + // Create block data from widget config + createBlockData := wshrpc.CommandCreateBlockData{ + TabId: tabId, + BlockDef: &widget.BlockDef, + Magnified: magnifyBlock || widget.Magnified, + Focused: true, + } + + // Create the block + oref, err := wshclient.CreateBlockCommand(RpcClient, createBlockData, nil) + if err != nil { + return fmt.Errorf("creating widget block: %w", err) + } + + WriteStdout("launched widget %q: %s\n", widgetId, oref) + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-notify.go b/cmd/wsh/cmd/wshcmd-notify.go new file mode 100644 index 000000000..c0d7cd6b2 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-notify.go @@ -0,0 +1,47 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" + "github.com/s-zx/crest/pkg/wshutil" +) + +var notifyTitle string +var notifySilent bool + +var setNotifyCmd = &cobra.Command{ + Use: "notify [-t ] [-s]", + Short: "create a notification", + Args: cobra.ExactArgs(1), + RunE: notifyRun, + PreRunE: preRunSetupRpcClient, +} + +func init() { + setNotifyCmd.Flags().StringVarP(¬ifyTitle, "title", "t", "Wsh Notify", "the notification title") + setNotifyCmd.Flags().BoolVarP(¬ifySilent, "silent", "s", false, "whether or not the notification sound is silenced") + rootCmd.AddCommand(setNotifyCmd) +} + +func notifyRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("notify", rtnErr == nil) + }() + message := args[0] + notificationOptions := &wshrpc.WaveNotificationOptions{ + Title: notifyTitle, + Body: message, + Silent: notifySilent, + } + err := wshclient.NotifyCommand(RpcClient, *notificationOptions, &wshrpc.RpcOpts{Timeout: 2000, Route: wshutil.ElectronRoute}) + if err != nil { + return fmt.Errorf("sending notification: %w", err) + } + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-rcfiles.go b/cmd/wsh/cmd/wshcmd-rcfiles.go new file mode 100644 index 000000000..c738bfd46 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-rcfiles.go @@ -0,0 +1,26 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/wshutil" +) + +func init() { + rootCmd.AddCommand(rcfilesCmd) +} + +var rcfilesCmd = &cobra.Command{ + Use: "rcfiles", + Hidden: true, + Short: "Generate the rc files needed for various shells", + Run: func(cmd *cobra.Command, args []string) { + err := wshutil.InstallRcFiles() + if err != nil { + WriteStderr("%s\n", err.Error()) + return + } + }, +} diff --git a/cmd/wsh/cmd/wshcmd-readfile.go b/cmd/wsh/cmd/wshcmd-readfile.go new file mode 100644 index 000000000..815259323 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-readfile.go @@ -0,0 +1,72 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "io" + "os" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" + "github.com/s-zx/crest/pkg/wshutil" +) + +var readFileCmd = &cobra.Command{ + Use: "readfile [filename]", + Short: "read a blockfile", + Args: cobra.ExactArgs(1), + Run: runReadFile, + PreRunE: preRunSetupRpcClient, + Hidden: true, +} + +func init() { + rootCmd.AddCommand(readFileCmd) +} + +func runReadFile(cmd *cobra.Command, args []string) { + fullORef, err := resolveBlockArg() + if err != nil { + WriteStderr("[error] %v\n", err) + return + } + + broker := RpcClient.StreamBroker + if broker == nil { + WriteStderr("[error] stream broker not available\n") + return + } + + readerRouteId, err := wshclient.ControlGetRouteIdCommand(RpcClient, &wshrpc.RpcOpts{Route: wshutil.ControlRoute}) + if err != nil { + WriteStderr("[error] getting route id: %v\n", err) + return + } + if readerRouteId == "" { + WriteStderr("[error] no route to receive data\n") + return + } + writerRouteId := "" + reader, streamMeta := broker.CreateStreamReader(readerRouteId, writerRouteId, 64*1024) + defer reader.Close() + + data := wshrpc.CommandWaveFileReadStreamData{ + ZoneId: fullORef.OID, + Name: args[0], + StreamMeta: *streamMeta, + } + + _, err = wshclient.WaveFileReadStreamCommand(RpcClient, data, nil) + if err != nil { + WriteStderr("[error] starting stream read: %v\n", err) + return + } + + _, err = io.Copy(os.Stdout, reader) + if err != nil { + WriteStderr("[error] reading stream: %v\n", err) + return + } +} diff --git a/cmd/wsh/cmd/wshcmd-root.go b/cmd/wsh/cmd/wshcmd-root.go new file mode 100644 index 000000000..7ec4455c4 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-root.go @@ -0,0 +1,251 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "io" + "os" + "runtime/debug" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/util/shellutil" + "github.com/s-zx/crest/pkg/waveobj" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" + "github.com/s-zx/crest/pkg/wshutil" +) + +var ( + rootCmd = &cobra.Command{ + Use: "wsh", + Short: "CLI tool to control Wave Terminal", + Long: `wsh is a small utility that lets you do cool things with Wave Terminal, right from the command line`, + SilenceUsage: true, + } +) + +var WrappedStdin io.Reader = os.Stdin +var WrappedStdout io.Writer = &WrappedWriter{dest: os.Stdout} +var WrappedStderr io.Writer = &WrappedWriter{dest: os.Stderr} +var RpcClient *wshutil.WshRpc +var RpcContext wshrpc.RpcContext +var RpcClientRouteId string +var UsingTermWshMode bool +var blockArg string +var WshExitCode int + +type WrappedWriter struct { + dest io.Writer +} + +func (w *WrappedWriter) Write(p []byte) (n int, err error) { + if !UsingTermWshMode { + return w.dest.Write(p) + } + count := 0 + for _, b := range p { + if b == '\n' { + count++ + } + } + if count == 0 { + return w.dest.Write(p) + } + buf := make([]byte, len(p)+count) // Each '\n' adds one extra byte for '\r' + writeIdx := 0 + for _, b := range p { + if b == '\n' { + buf[writeIdx] = '\r' + buf[writeIdx+1] = '\n' + writeIdx += 2 + } else { + buf[writeIdx] = b + writeIdx++ + } + } + return w.dest.Write(buf) +} + +func WriteStderr(fmtStr string, args ...interface{}) { + WrappedStderr.Write([]byte(fmt.Sprintf(fmtStr, args...))) +} + +func WriteStdout(fmtStr string, args ...interface{}) { + WrappedStdout.Write([]byte(fmt.Sprintf(fmtStr, args...))) +} + +func OutputHelpMessage(cmd *cobra.Command) { + cmd.SetOutput(WrappedStderr) + cmd.Help() + WriteStderr("\n") +} + +func preRunSetupRpcClient(cmd *cobra.Command, args []string) error { + jwtToken := os.Getenv(wshutil.WaveJwtTokenVarName) + if jwtToken == "" { + return fmt.Errorf("wsh must be run inside a Wave-managed SSH session (WAVETERM_JWT not found)") + } + err := setupRpcClient(nil, jwtToken) + if err != nil { + return err + } + return nil +} + +func getIsTty() bool { + if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 { + return true + } + return false +} + +type RunEFnType = func(*cobra.Command, []string) error + +func activityWrap(activityStr string, origRunE RunEFnType) RunEFnType { + return func(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity(activityStr, rtnErr == nil) + }() + return origRunE(cmd, args) + } +} + +func resolveBlockArg() (*waveobj.ORef, error) { + oref := blockArg + if oref == "" { + oref = "this" + } + fullORef, err := resolveSimpleId(oref) + if err != nil { + return nil, fmt.Errorf("resolving blockid: %w", err) + } + return fullORef, nil +} + +func setupRpcClientWithToken(swapTokenStr string) (wshrpc.CommandAuthenticateRtnData, error) { + var rtn wshrpc.CommandAuthenticateRtnData + token, err := shellutil.UnpackSwapToken(swapTokenStr) + if err != nil { + return rtn, fmt.Errorf("error unpacking token: %w", err) + } + if token.RpcContext == nil { + return rtn, fmt.Errorf("no rpccontext in token") + } + if token.RpcContext.SockName == "" { + return rtn, fmt.Errorf("no sockname in token") + } + RpcContext = *token.RpcContext + RpcClient, err = wshutil.SetupDomainSocketRpcClient(token.RpcContext.SockName, nil, "wshcmd") + if err != nil { + return rtn, fmt.Errorf("error setting up domain socket rpc client: %w", err) + } + rtn, err = wshclient.AuthenticateTokenCommand(RpcClient, wshrpc.CommandAuthenticateTokenData{Token: token.Token}, &wshrpc.RpcOpts{Route: wshutil.ControlRoute}) + if err != nil { + return rtn, err + } + RpcClientRouteId = rtn.RouteId + return rtn, nil +} + +// returns the wrapped stdin and a new rpc client (that wraps the stdin input and stdout output) +func setupRpcClient(serverImpl wshutil.ServerImpl, jwtToken string) error { + rpcCtx, err := wshutil.ExtractUnverifiedRpcContext(jwtToken) + if err != nil { + return fmt.Errorf("error extracting rpc context from %s: %v", wshutil.WaveJwtTokenVarName, err) + } + RpcContext = *rpcCtx + sockName, err := wshutil.ExtractUnverifiedSocketName(jwtToken) + if err != nil { + return fmt.Errorf("error extracting socket name from %s: %v", wshutil.WaveJwtTokenVarName, err) + } + RpcClient, err = wshutil.SetupDomainSocketRpcClient(sockName, serverImpl, "wshcmd") + if err != nil { + return fmt.Errorf("error setting up domain socket rpc client: %v", err) + } + authRtn, err := wshclient.AuthenticateCommand(RpcClient, jwtToken, &wshrpc.RpcOpts{Route: wshutil.ControlRoute}) + if err != nil { + return fmt.Errorf("error authenticating: %v", err) + } + RpcClientRouteId = authRtn.RouteId + blockId := os.Getenv("WAVETERM_BLOCKID") + if blockId != "" { + peerInfo := fmt.Sprintf("domain:block:%s", blockId) + wshclient.SetPeerInfoCommand(RpcClient, peerInfo, &wshrpc.RpcOpts{Route: wshutil.ControlRoute}) + } + // note we don't modify WrappedStdin here (just use os.Stdin) + return nil +} + +func isFullORef(orefStr string) bool { + _, err := waveobj.ParseORef(orefStr) + return err == nil +} + +func resolveSimpleId(id string) (*waveobj.ORef, error) { + if isFullORef(id) { + orefObj, err := waveobj.ParseORef(id) + if err != nil { + return nil, fmt.Errorf("error parsing full ORef: %v", err) + } + return &orefObj, nil + } + blockId := os.Getenv("WAVETERM_BLOCKID") + if blockId == "" { + return nil, fmt.Errorf("no WAVETERM_BLOCKID env var set") + } + rtnData, err := wshclient.ResolveIdsCommand(RpcClient, wshrpc.CommandResolveIdsData{ + BlockId: blockId, + Ids: []string{id}, + }, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return nil, fmt.Errorf("error resolving ids: %v", err) + } + oref, ok := rtnData.ResolvedIds[id] + if !ok { + return nil, fmt.Errorf("id not found: %q", id) + } + return &oref, nil +} + +func getTabIdFromEnv() string { + return os.Getenv("WAVETERM_TABID") +} + +// this will send wsh activity to the client running on *your* local machine (it does not contact any wave cloud infrastructure) +// if you've turned off telemetry in your local client, this data never gets sent to us +// no parameters or timestamps are sent, as you can see below, it just sends the name of the command (and if there was an error) +// (e.g. "wsh ai ..." would send "ai") +// this helps us understand which commands are actually being used so we know where to concentrate our effort +func sendActivity(wshCmdName string, success bool) { + if RpcClient == nil || wshCmdName == "" { + return + } + dataMap := make(map[string]int) + dataMap[wshCmdName] = 1 + if !success { + dataMap[wshCmdName+"#"+"error"] = 1 + } + wshclient.WshActivityCommand(RpcClient, dataMap, nil) +} + +// Execute executes the root command. +func Execute() { + defer func() { + r := recover() + if r != nil { + WriteStderr("[panic] %v\n", r) + debug.PrintStack() + wshutil.DoShutdown("", 1, true) + } else { + wshutil.DoShutdown("", WshExitCode, false) + } + }() + rootCmd.PersistentFlags().StringVarP(&blockArg, "block", "b", "", "for commands which require a block id") + err := rootCmd.Execute() + if err != nil { + wshutil.DoShutdown("", 1, true) + return + } +} diff --git a/cmd/wsh/cmd/wshcmd-run.go b/cmd/wsh/cmd/wshcmd-run.go new file mode 100644 index 000000000..99d031acb --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-run.go @@ -0,0 +1,162 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/util/envutil" + "github.com/s-zx/crest/pkg/wavebase" + "github.com/s-zx/crest/pkg/waveobj" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +var runCmd = &cobra.Command{ + Use: "run [flags] -- command [args...]", + Short: "run a command in a new block", + RunE: runRun, + PreRunE: preRunSetupRpcClient, + TraverseChildren: true, +} + +func init() { + flags := runCmd.Flags() + flags.BoolP("magnified", "m", false, "open view in magnified mode") + flags.StringP("command", "c", "", "run command string in shell") + flags.BoolP("exit", "x", false, "close block if command exits successfully (will stay open if there was an error)") + flags.BoolP("forceexit", "X", false, "close block when command exits, regardless of exit status") + flags.IntP("delay", "", 2000, "if -x, delay in milliseconds before closing block") + flags.BoolP("paused", "p", false, "create block in paused state") + flags.String("cwd", "", "set working directory for command") + flags.BoolP("append", "a", false, "append output on restart instead of clearing") + rootCmd.AddCommand(runCmd) +} + +func runRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("run", rtnErr == nil) + }() + + flags := cmd.Flags() + magnified, _ := flags.GetBool("magnified") + commandArg, _ := flags.GetString("command") + exit, _ := flags.GetBool("exit") + forceExit, _ := flags.GetBool("forceexit") + paused, _ := flags.GetBool("paused") + cwd, _ := flags.GetString("cwd") + delayMs, _ := flags.GetInt("delay") + appendOutput, _ := flags.GetBool("append") + var cmdArgs []string + var useShell bool + var shellCmd string + + for i, arg := range os.Args { + if arg == "--" { + if i+1 >= len(os.Args) { + OutputHelpMessage(cmd) + return fmt.Errorf("no command provided after --") + } + shellCmd = os.Args[i+1] + cmdArgs = os.Args[i+2:] + break + } + } + if shellCmd != "" && commandArg != "" { + OutputHelpMessage(cmd) + return fmt.Errorf("cannot specify both -c and command arguments") + } + if shellCmd == "" && commandArg == "" { + OutputHelpMessage(cmd) + return fmt.Errorf("command must be specified after -- or with -c") + } + if commandArg != "" { + shellCmd = commandArg + useShell = true + } + + // Get current working directory + if cwd == "" { + var err error + cwd, err = os.Getwd() + if err != nil { + return fmt.Errorf("getting current directory: %w", err) + } + } + cwd, err := filepath.Abs(cwd) + if err != nil { + return fmt.Errorf("getting absolute path: %w", err) + } + + // Get current environment and convert to map + envMap := make(map[string]string) + for _, envStr := range os.Environ() { + env := strings.SplitN(envStr, "=", 2) + if len(env) == 2 { + envMap[env[0]] = env[1] + } + } + + // Convert to null-terminated format + envContent := envutil.MapToEnv(envMap) + createMeta := map[string]any{ + waveobj.MetaKey_View: "term", + waveobj.MetaKey_CmdCwd: cwd, + waveobj.MetaKey_Controller: "cmd", + waveobj.MetaKey_CmdClearOnStart: true, + } + createMeta[waveobj.MetaKey_Cmd] = shellCmd + createMeta[waveobj.MetaKey_CmdArgs] = cmdArgs + createMeta[waveobj.MetaKey_CmdShell] = useShell + if paused { + createMeta[waveobj.MetaKey_CmdRunOnStart] = false + } else { + createMeta[waveobj.MetaKey_CmdRunOnce] = true + createMeta[waveobj.MetaKey_CmdRunOnStart] = true + } + if forceExit { + createMeta[waveobj.MetaKey_CmdCloseOnExitForce] = true + } else if exit { + createMeta[waveobj.MetaKey_CmdCloseOnExit] = true + } + createMeta[waveobj.MetaKey_CmdCloseOnExitDelay] = float64(delayMs) + if appendOutput { + createMeta[waveobj.MetaKey_CmdClearOnStart] = false + } + + if RpcContext.Conn != "" { + createMeta[waveobj.MetaKey_Connection] = RpcContext.Conn + } + + tabId := getTabIdFromEnv() + if tabId == "" { + return fmt.Errorf("no WAVETERM_TABID env var set") + } + + createBlockData := wshrpc.CommandCreateBlockData{ + TabId: tabId, + BlockDef: &waveobj.BlockDef{ + Meta: createMeta, + Files: map[string]*waveobj.FileDef{ + wavebase.BlockFile_Env: { + Content: envContent, + }, + }, + }, + Magnified: magnified, + Focused: true, + } + + oref, err := wshclient.CreateBlockCommand(RpcClient, createBlockData, nil) + if err != nil { + return fmt.Errorf("creating new run block: %w", err) + } + + WriteStdout("run block created: %s\n", oref) + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-secret.go b/cmd/wsh/cmd/wshcmd-secret.go new file mode 100644 index 000000000..757180a1a --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-secret.go @@ -0,0 +1,201 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "regexp" + "strings" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/waveobj" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +// secretNameRegex must match the validation in pkg/wconfig/secretstore.go +var secretNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`) + +var secretUiMagnified bool + +var secretCmd = &cobra.Command{ + Use: "secret", + Short: "manage secrets", + Long: "Manage secrets for Wave Terminal", +} + +var secretGetCmd = &cobra.Command{ + Use: "get [name]", + Short: "get a secret value", + Args: cobra.ExactArgs(1), + RunE: secretGetRun, + PreRunE: preRunSetupRpcClient, +} + +var secretSetCmd = &cobra.Command{ + Use: "set [name]=[value]", + Short: "set a secret value", + Args: cobra.ExactArgs(1), + RunE: secretSetRun, + PreRunE: preRunSetupRpcClient, +} + +var secretListCmd = &cobra.Command{ + Use: "list", + Short: "list all secret names", + Args: cobra.NoArgs, + RunE: secretListRun, + PreRunE: preRunSetupRpcClient, +} + +var secretDeleteCmd = &cobra.Command{ + Use: "delete [name]", + Short: "delete a secret", + Args: cobra.ExactArgs(1), + RunE: secretDeleteRun, + PreRunE: preRunSetupRpcClient, +} + +var secretUiCmd = &cobra.Command{ + Use: "ui", + Short: "open secrets UI", + Args: cobra.NoArgs, + RunE: secretUiRun, + PreRunE: preRunSetupRpcClient, +} + +func init() { + secretUiCmd.Flags().BoolVarP(&secretUiMagnified, "magnified", "m", false, "open secrets UI in magnified mode") + rootCmd.AddCommand(secretCmd) + secretCmd.AddCommand(secretGetCmd) + secretCmd.AddCommand(secretSetCmd) + secretCmd.AddCommand(secretListCmd) + secretCmd.AddCommand(secretDeleteCmd) + secretCmd.AddCommand(secretUiCmd) +} + +func secretGetRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("secret", rtnErr == nil) + }() + + name := args[0] + if !secretNameRegex.MatchString(name) { + return fmt.Errorf("invalid secret name: must start with a letter and contain only letters, numbers, and underscores") + } + + resp, err := wshclient.GetSecretsCommand(RpcClient, []string{name}, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("getting secret: %w", err) + } + + value, ok := resp[name] + if !ok { + return fmt.Errorf("secret not found: %s", name) + } + + WriteStdout("%s\n", value) + return nil +} + +func secretSetRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("secret", rtnErr == nil) + }() + + parts := strings.SplitN(args[0], "=", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid format: expected [name]=[value]") + } + + name := parts[0] + value := parts[1] + + if name == "" { + return fmt.Errorf("secret name cannot be empty") + } + + backend, err := wshclient.GetSecretsLinuxStorageBackendCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("checking secret storage backend: %w", err) + } + + if backend == "basic_text" || backend == "unknown" { + return fmt.Errorf("No appropriate secret manager found, cannot set secrets") + } + + secrets := map[string]*string{name: &value} + err = wshclient.SetSecretsCommand(RpcClient, secrets, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("setting secret: %w", err) + } + + WriteStdout("secret set: %s\n", name) + return nil +} + +func secretListRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("secret", rtnErr == nil) + }() + + names, err := wshclient.GetSecretsNamesCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("listing secrets: %w", err) + } + + for _, name := range names { + WriteStdout("%s\n", name) + } + return nil +} + +func secretDeleteRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("secret", rtnErr == nil) + }() + + name := args[0] + if !secretNameRegex.MatchString(name) { + return fmt.Errorf("invalid secret name: must start with a letter and contain only letters, numbers, and underscores") + } + + secrets := map[string]*string{name: nil} + err := wshclient.SetSecretsCommand(RpcClient, secrets, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("deleting secret: %w", err) + } + + WriteStdout("secret deleted: %s\n", name) + return nil +} + +func secretUiRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("secret", rtnErr == nil) + }() + + tabId := getTabIdFromEnv() + if tabId == "" { + return fmt.Errorf("no WAVETERM_TABID env var set") + } + + wshCmd := &wshrpc.CommandCreateBlockData{ + TabId: tabId, + BlockDef: &waveobj.BlockDef{ + Meta: map[string]interface{}{ + waveobj.MetaKey_View: "waveconfig", + waveobj.MetaKey_File: "secrets", + }, + }, + Magnified: secretUiMagnified, + Focused: true, + } + + _, err := wshclient.CreateBlockCommand(RpcClient, *wshCmd, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("opening secrets UI: %w", err) + } + return nil +} \ No newline at end of file diff --git a/cmd/wsh/cmd/wshcmd-setbg.go b/cmd/wsh/cmd/wshcmd-setbg.go new file mode 100644 index 000000000..738f51b29 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-setbg.go @@ -0,0 +1,230 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/util/fileutil" + "github.com/s-zx/crest/pkg/wavebase" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +var setBgCmd = &cobra.Command{ + Use: "setbg [--opacity value] [--tile|--center] [--scale value] [--border-color color] [--active-border-color color] (image-path|\"#color\"|color-name)", + Short: "set background image or color for a tab", + Long: `Set a background image or color for a tab. Colors can be specified as: + - A quoted hex value like "#ff0000" (quotes required to prevent # being interpreted as a shell comment) + - A CSS color name like "blue" or "forestgreen" +Or provide a path to a supported image file (jpg, png, gif, webp, or svg). + +You can also: + - Use --clear to remove the background + - Use --opacity without other arguments to change just the opacity + - Use --center for centered images without scaling (good for logos) + - Use --scale with --center to control image size + - Use --border-color to set the block frame border color + - Use --active-border-color to set the block frame focused border color + - Use --print to see the metadata without applying it`, + RunE: setBgRun, + PreRunE: preRunSetupRpcClient, +} + +var ( + setBgOpacity float64 + setBgTile bool + setBgCenter bool + setBgSize string + setBgClear bool + setBgPrint bool + setBgBorderColor string + setBgActiveBorderColor string +) + +func init() { + rootCmd.AddCommand(setBgCmd) + setBgCmd.Flags().Float64Var(&setBgOpacity, "opacity", 0.5, "background opacity (0.0-1.0)") + setBgCmd.Flags().BoolVar(&setBgTile, "tile", false, "tile the background image") + setBgCmd.Flags().BoolVar(&setBgCenter, "center", false, "center the image without scaling") + setBgCmd.Flags().StringVar(&setBgSize, "size", "auto", "size for centered images (px, %, or auto)") + setBgCmd.Flags().BoolVar(&setBgClear, "clear", false, "clear the background") + setBgCmd.Flags().BoolVar(&setBgPrint, "print", false, "print the metadata without applying it") + setBgCmd.Flags().StringVar(&setBgBorderColor, "border-color", "", "block frame border color (#RRGGBB, #RRGGBBAA, or CSS color name)") + setBgCmd.Flags().StringVar(&setBgActiveBorderColor, "active-border-color", "", "block frame focused border color (#RRGGBB, #RRGGBBAA, or CSS color name)") + + setBgCmd.MarkFlagsMutuallyExclusive("tile", "center") +} + +func validateHexColor(color string) error { + if !strings.HasPrefix(color, "#") { + return fmt.Errorf("color must start with #") + } + colorHex := color[1:] + if len(colorHex) != 6 && len(colorHex) != 8 { + return fmt.Errorf("color must be in #RRGGBB or #RRGGBBAA format") + } + _, err := hex.DecodeString(colorHex) + if err != nil { + return fmt.Errorf("invalid hex color: %v", err) + } + return nil +} + +func validateColor(color string) error { + if strings.HasPrefix(color, "#") { + return validateHexColor(color) + } + if !CssColorNames[strings.ToLower(color)] { + return fmt.Errorf("invalid color %q: must be a hex color (#RRGGBB or #RRGGBBAA) or a CSS color name", color) + } + return nil +} + +func setBgRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("setbg", rtnErr == nil) + }() + + borderColorChanged := cmd.Flags().Changed("border-color") + activeBorderColorChanged := cmd.Flags().Changed("active-border-color") + + if borderColorChanged { + if err := validateColor(setBgBorderColor); err != nil { + return fmt.Errorf("--border-color: %v", err) + } + } + if activeBorderColorChanged { + if err := validateColor(setBgActiveBorderColor); err != nil { + return fmt.Errorf("--active-border-color: %v", err) + } + } + + // Create base metadata + meta := map[string]interface{}{} + + // Handle opacity-only change or clear + if len(args) == 0 { + if !cmd.Flags().Changed("opacity") && !setBgClear && !borderColorChanged && !activeBorderColorChanged { + OutputHelpMessage(cmd) + return fmt.Errorf("setbg requires an image path or color value") + } + if setBgOpacity < 0 || setBgOpacity > 1 { + return fmt.Errorf("opacity must be between 0.0 and 1.0") + } + if setBgClear { + meta["bg:*"] = true + } else if cmd.Flags().Changed("opacity") { + meta["bg:opacity"] = setBgOpacity + } + } else if len(args) > 1 { + OutputHelpMessage(cmd) + return fmt.Errorf("too many arguments") + } else { + // Handle background setting + meta["bg:*"] = true + meta["tab:background"] = nil + if setBgOpacity < 0 || setBgOpacity > 1 { + return fmt.Errorf("opacity must be between 0.0 and 1.0") + } + meta["bg:opacity"] = setBgOpacity + + input := args[0] + var bgStyle string + + // Check for hex color + if strings.HasPrefix(input, "#") { + if err := validateHexColor(input); err != nil { + return err + } + bgStyle = input + } else if CssColorNames[strings.ToLower(input)] { + // Handle CSS color name + bgStyle = strings.ToLower(input) + } else { + // Handle image input + absPath, err := filepath.Abs(wavebase.ExpandHomeDirSafe(input)) + if err != nil { + return fmt.Errorf("resolving image path: %v", err) + } + + fileInfo, err := os.Stat(absPath) + if err != nil { + return fmt.Errorf("cannot access image file: %v", err) + } + if fileInfo.IsDir() { + return fmt.Errorf("path is a directory, not an image file") + } + + mimeType := fileutil.DetectMimeType(absPath, fileInfo, true) + switch mimeType { + case "image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml": + // Valid image type + default: + return fmt.Errorf("file does not appear to be a valid image (detected type: %s)", mimeType) + } + + // Create URL-safe path + escapedPath := filepath.ToSlash(absPath) + escapedPath = strings.ReplaceAll(escapedPath, "'", "\\'") + bgStyle = fmt.Sprintf("url('%s')", escapedPath) + + switch { + case setBgTile: + bgStyle += " repeat" + case setBgCenter: + bgStyle += fmt.Sprintf(" no-repeat center/%s", setBgSize) + default: + bgStyle += " center/cover no-repeat" + } + } + + meta["bg"] = bgStyle + } + + if borderColorChanged { + meta["bg:bordercolor"] = setBgBorderColor + } + if activeBorderColorChanged { + meta["bg:activebordercolor"] = setBgActiveBorderColor + } + + if setBgPrint { + jsonBytes, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return fmt.Errorf("error formatting metadata: %v", err) + } + WriteStdout("%s\n", string(jsonBytes)) + return nil + } + + // Resolve tab reference + id := blockArg + if id == "" { + id = "tab" + } + oRef, err := resolveSimpleId(id) + if err != nil { + return err + } + + // Send RPC request + setMetaWshCmd := wshrpc.CommandSetMetaData{ + ORef: *oRef, + Meta: meta, + } + err = wshclient.SetMetaCommand(RpcClient, setMetaWshCmd, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("setting background: %v", err) + } + + WriteStdout("background set\n") + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-setconfig.go b/cmd/wsh/cmd/wshcmd-setconfig.go new file mode 100644 index 000000000..75e35edc9 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-setconfig.go @@ -0,0 +1,43 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +var setConfigCmd = &cobra.Command{ + Use: "setconfig", + Short: "set config", + Args: cobra.MinimumNArgs(1), + RunE: setConfigRun, + PreRunE: preRunSetupRpcClient, +} + +func init() { + rootCmd.AddCommand(setConfigCmd) +} + +func setConfigRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("setconfig", rtnErr == nil) + }() + + metaSetsStrs := args[:] + meta, err := parseMetaSets(metaSetsStrs) + if err != nil { + return err + } + commandData := wshrpc.MetaSettingsType{MetaMapType: meta} + err = wshclient.SetConfigCommand(RpcClient, commandData, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("setting config: %w", err) + } + WriteStdout("config set\n") + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-setmeta.go b/cmd/wsh/cmd/wshcmd-setmeta.go new file mode 100644 index 000000000..7ce0abe2e --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-setmeta.go @@ -0,0 +1,202 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strconv" + "strings" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +var setMetaCmd = &cobra.Command{ + Use: "setmeta [-b {blockid|blocknum|this}] [--json file.json] key=value ...", + Short: "set metadata for an entity", + Args: cobra.MinimumNArgs(0), + RunE: setMetaRun, + PreRunE: preRunSetupRpcClient, +} + +var setMetaJsonFilePath string + +func init() { + rootCmd.AddCommand(setMetaCmd) + setMetaCmd.Flags().StringVar(&setMetaJsonFilePath, "json", "", "JSON file containing metadata to apply (use '-' for stdin)") +} + +func loadJSONFile(filepath string) (map[string]interface{}, error) { + var data []byte + var err error + + if filepath == "-" { + data, err = io.ReadAll(os.Stdin) + if err != nil { + return nil, fmt.Errorf("reading from stdin: %v", err) + } + } else { + data, err = os.ReadFile(filepath) + if err != nil { + return nil, fmt.Errorf("reading JSON file: %v", err) + } + } + + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("parsing JSON file: %v", err) + } + + if result == nil { + return nil, fmt.Errorf("JSON file must contain an object, not null") + } + + return result, nil +} + +func parseMetaValue(setVal string) (any, error) { + if setVal == "" || setVal == "null" { + return nil, nil + } + if setVal == "true" { + return true, nil + } + if setVal == "false" { + return false, nil + } + if setVal[0] == '[' || setVal[0] == '{' || setVal[0] == '"' { + var val any + err := json.Unmarshal([]byte(setVal), &val) + if err != nil { + return nil, fmt.Errorf("invalid json value: %v", err) + } + return val, nil + } + + // Try parsing as integer + ival, err := strconv.ParseInt(setVal, 0, 64) + if err == nil { + return ival, nil + } + + // Try parsing as float + fval, err := strconv.ParseFloat(setVal, 64) + if err == nil { + return fval, nil + } + + // Fallback to string + return setVal, nil +} + +func setNestedValue(meta map[string]any, path []string, value any) { + // For single key, just set directly + if len(path) == 1 { + meta[path[0]] = value + return + } + + // For nested path, traverse or create maps as needed + current := meta + for i := 0; i < len(path)-1; i++ { + key := path[i] + // If next level doesn't exist or isn't a map, create new map + next, exists := current[key] + if !exists { + nextMap := make(map[string]any) + current[key] = nextMap + current = nextMap + } else if nextMap, ok := next.(map[string]any); ok { + current = nextMap + } else { + // If existing value isn't a map, replace with new map + nextMap = make(map[string]any) + current[key] = nextMap + current = nextMap + } + } + + // Set the final value + current[path[len(path)-1]] = value +} + +func parseMetaSets(metaSets []string) (map[string]any, error) { + meta := make(map[string]any) + for _, metaSet := range metaSets { + fields := strings.SplitN(metaSet, "=", 2) + if len(fields) != 2 { + return nil, fmt.Errorf("invalid meta set: %q", metaSet) + } + + val, err := parseMetaValue(fields[1]) + if err != nil { + return nil, err + } + + // Split the key path and set nested value + path := strings.Split(fields[0], "/") + setNestedValue(meta, path, val) + } + return meta, nil +} + +func simpleMergeMeta(meta map[string]interface{}, metaUpdate map[string]interface{}) map[string]interface{} { + for k, v := range metaUpdate { + if v == nil { + delete(meta, k) + } else { + meta[k] = v + } + } + return meta +} + +func setMetaRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("setmeta", rtnErr == nil) + }() + var jsonMeta map[string]interface{} + if setMetaJsonFilePath != "" { + var err error + jsonMeta, err = loadJSONFile(setMetaJsonFilePath) + if err != nil { + return err + } + } + + cmdMeta, err := parseMetaSets(args) + if err != nil { + return err + } + + // Merge JSON metadata with command-line metadata, with command-line taking precedence + var fullMeta map[string]any + if len(jsonMeta) > 0 { + fullMeta = simpleMergeMeta(jsonMeta, cmdMeta) + } else { + fullMeta = cmdMeta + } + if len(fullMeta) == 0 { + return fmt.Errorf("no metadata keys specified") + } + fullORef, err := resolveBlockArg() + if err != nil { + return err + } + + setMetaWshCmd := &wshrpc.CommandSetMetaData{ + ORef: *fullORef, + Meta: fullMeta, + } + err = wshclient.SetMetaCommand(RpcClient, *setMetaWshCmd, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("setting metadata: %v", err) + } + WriteStdout("metadata set\n") + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-setvar.go b/cmd/wsh/cmd/wshcmd-setvar.go new file mode 100644 index 000000000..71ddba769 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-setvar.go @@ -0,0 +1,101 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +const DefaultVarFileName = "var" + +var setVarCmd = &cobra.Command{ + Use: "setvar [flags] KEY=VALUE...", + Short: "set variable(s) for a block", + Long: `Set one or more variables for a block. +Use --remove/-r to remove variables instead of setting them. +When setting, each argument must be in KEY=VALUE format. +When removing, each argument is treated as a key to remove.`, + Example: " wsh setvar FOO=bar BAZ=123\n wsh setvar -r FOO BAZ", + Args: cobra.MinimumNArgs(1), + RunE: setVarRun, + PreRunE: preRunSetupRpcClient, +} + +var ( + setVarFileName string + setVarRemoveVar bool + setVarLocal bool +) + +func init() { + rootCmd.AddCommand(setVarCmd) + setVarCmd.Flags().StringVar(&setVarFileName, "varfile", DefaultVarFileName, "var file name") + setVarCmd.Flags().BoolVarP(&setVarLocal, "local", "l", false, "set variables local to block") + setVarCmd.Flags().BoolVarP(&setVarRemoveVar, "remove", "r", false, "remove the variable(s) instead of setting") +} + +func parseKeyValue(arg string) (key, value string, err error) { + if setVarRemoveVar { + return arg, "", nil + } + + parts := strings.SplitN(arg, "=", 2) + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid KEY=VALUE format %q (= sign required)", arg) + } + key = parts[0] + if key == "" { + return "", "", fmt.Errorf("empty key not allowed") + } + return key, parts[1], nil +} + +func setVarRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("setvar", rtnErr == nil) + }() + + // Resolve block to get zoneId + if blockArg == "" { + if getVarLocal { + blockArg = "this" + } else { + blockArg = "client" + } + } + fullORef, err := resolveBlockArg() + if err != nil { + return err + } + + // Process all variables + for _, arg := range args { + key, value, err := parseKeyValue(arg) + if err != nil { + return err + } + + commandData := wshrpc.CommandVarData{ + Key: key, + ZoneId: fullORef.OID, + FileName: setVarFileName, + Remove: setVarRemoveVar, + } + + if !setVarRemoveVar { + commandData.Val = value + } + + err = wshclient.SetVarCommand(RpcClient, commandData, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("setting variable %s: %w", key, err) + } + } + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-shell-unix.go b/cmd/wsh/cmd/wshcmd-shell-unix.go new file mode 100644 index 000000000..e57f03f97 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-shell-unix.go @@ -0,0 +1,40 @@ +//go:build !windows + +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "os" + "runtime" + "strings" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/util/shellutil" +) + +func init() { + rootCmd.AddCommand(shellCmd) +} + +var shellCmd = &cobra.Command{ + Use: "shell", + Hidden: true, + Short: "Print the login shell of this user", + Run: func(cmd *cobra.Command, args []string) { + WriteStdout("%s", shellCmdInner()) + }, +} + +func shellCmdInner() string { + if runtime.GOOS == "darwin" { + return shellutil.GetMacUserShell() + "\n" + } + + shell := os.Getenv("SHELL") + if shell == "" { + return "/bin/bash\n" + } + return strings.TrimSpace(shell) + "\n" +} diff --git a/cmd/wsh/cmd/wshcmd-shell-win.go b/cmd/wsh/cmd/wshcmd-shell-win.go new file mode 100644 index 000000000..a218eebb8 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-shell-win.go @@ -0,0 +1,27 @@ +//go:build windows + +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(shellCmd) +} + +var shellCmd = &cobra.Command{ + Use: "shell", + Hidden: true, + Short: "Print the login shell of this user", + Run: func(cmd *cobra.Command, args []string) { + shellCmdInner() + }, +} + +func shellCmdInner() { + WriteStderr("not implemented/n") +} diff --git a/cmd/wsh/cmd/wshcmd-ssh.go b/cmd/wsh/cmd/wshcmd-ssh.go new file mode 100644 index 000000000..63dfc1aa9 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-ssh.go @@ -0,0 +1,127 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/remote" + "github.com/s-zx/crest/pkg/waveobj" + "github.com/s-zx/crest/pkg/wconfig" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +var ( + identityFiles []string + sshLogin string + sshPort string + newBlock bool +) + +var sshCmd = &cobra.Command{ + Use: "ssh", + Short: "connect this terminal to a remote host", + Args: cobra.ExactArgs(1), + RunE: sshRun, + PreRunE: preRunSetupRpcClient, +} + +func init() { + sshCmd.Flags().StringArrayVarP(&identityFiles, "identityfile", "i", []string{}, "add an identity file for publickey authentication") + sshCmd.Flags().StringVarP(&sshLogin, "login", "l", "", "set the remote login name") + sshCmd.Flags().StringVarP(&sshPort, "port", "p", "", "set the remote port") + sshCmd.Flags().BoolVarP(&newBlock, "new", "n", false, "create a new terminal block with this connection") + rootCmd.AddCommand(sshCmd) +} + +func sshRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("ssh", rtnErr == nil) + }() + + sshArg := args[0] + var err error + sshArg, err = applySSHOverrides(sshArg, sshLogin, sshPort) + if err != nil { + return err + } + blockId := RpcContext.BlockId + if blockId == "" && !newBlock { + return fmt.Errorf("cannot determine blockid (not in JWT)") + } + + // Create connection request + connOpts := wshrpc.ConnRequest{ + Host: sshArg, + LogBlockId: blockId, + Keywords: wconfig.ConnKeywords{ + SshIdentityFile: identityFiles, + }, + } + wshclient.ConnConnectCommand(RpcClient, connOpts, &wshrpc.RpcOpts{Timeout: 60000}) + + if newBlock { + tabId := getTabIdFromEnv() + if tabId == "" { + return fmt.Errorf("no WAVETERM_TABID env var set") + } + + // Create a new block with the SSH connection + createMeta := map[string]any{ + waveobj.MetaKey_View: "term", + waveobj.MetaKey_Controller: "shell", + waveobj.MetaKey_Connection: sshArg, + } + if RpcContext.Conn != "" { + createMeta[waveobj.MetaKey_Connection] = RpcContext.Conn + } + createBlockData := wshrpc.CommandCreateBlockData{ + TabId: tabId, + BlockDef: &waveobj.BlockDef{ + Meta: createMeta, + }, + Focused: true, + } + oref, err := wshclient.CreateBlockCommand(RpcClient, createBlockData, nil) + if err != nil { + return fmt.Errorf("creating new terminal block: %w", err) + } + WriteStdout("new terminal block created with connection to %q: %s\n", sshArg, oref) + return nil + } + + // Update existing block with the new connection + data := wshrpc.CommandSetMetaData{ + ORef: waveobj.MakeORef(waveobj.OType_Block, blockId), + Meta: map[string]any{ + waveobj.MetaKey_Connection: sshArg, + waveobj.MetaKey_CmdCwd: nil, + }, + } + err = wshclient.SetMetaCommand(RpcClient, data, nil) + if err != nil { + return fmt.Errorf("setting connection in block: %w", err) + } + WriteStderr("switched connection to %q\n", sshArg) + return nil +} + +func applySSHOverrides(sshArg string, login string, port string) (string, error) { + if login == "" && port == "" { + return sshArg, nil + } + opts, err := remote.ParseOpts(sshArg) + if err != nil { + return "", err + } + if login != "" { + opts.SSHUser = login + } + if port != "" { + opts.SSHPort = port + } + return opts.String(), nil +} diff --git a/cmd/wsh/cmd/wshcmd-ssh_test.go b/cmd/wsh/cmd/wshcmd-ssh_test.go new file mode 100644 index 000000000..36da03746 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-ssh_test.go @@ -0,0 +1,75 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import "testing" + +func TestApplySSHOverrides(t *testing.T) { + tests := []struct { + name string + sshArg string + login string + port string + want string + wantErr bool + }{ + { + name: "no overrides preserves target", + sshArg: "root@bar.com:2022", + want: "root@bar.com:2022", + }, + { + name: "login override replaces parsed user", + sshArg: "root@bar.com", + login: "foo", + want: "foo@bar.com", + }, + { + name: "port override replaces parsed port", + sshArg: "root@bar.com:2022", + port: "2222", + want: "root@bar.com:2222", + }, + { + name: "both overrides replace parsed user and port", + sshArg: "root@bar.com:2022", + login: "foo", + port: "2200", + want: "foo@bar.com:2200", + }, + { + name: "login override adds user to bare host", + sshArg: "bar.com", + login: "foo", + want: "foo@bar.com", + }, + { + name: "port override adds port to bare host", + sshArg: "bar.com", + port: "2200", + want: "bar.com:2200", + }, + { + name: "invalid target returns parse error when override requested", + sshArg: "bad host", + login: "foo", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := applySSHOverrides(tt.sshArg, tt.login, tt.port) + if (err != nil) != tt.wantErr { + t.Fatalf("applySSHOverrides() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + if got != tt.want { + t.Fatalf("applySSHOverrides() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/cmd/wsh/cmd/wshcmd-tabindicator.go b/cmd/wsh/cmd/wshcmd-tabindicator.go new file mode 100644 index 000000000..c53e9d216 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-tabindicator.go @@ -0,0 +1,107 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "os" + + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/baseds" + "github.com/s-zx/crest/pkg/waveobj" + "github.com/s-zx/crest/pkg/wps" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +var tabIndicatorCmd = &cobra.Command{ + Use: "tabindicator [icon]", + Short: "set or clear a tab indicator (deprecated: use 'wsh badge')", + Args: cobra.MaximumNArgs(1), + RunE: tabIndicatorRun, + PreRunE: preRunSetupRpcClient, +} + +var ( + tabIndicatorTabId string + tabIndicatorColor string + tabIndicatorPriority float64 + tabIndicatorClear bool + tabIndicatorBeep bool +) + +func init() { + rootCmd.AddCommand(tabIndicatorCmd) + tabIndicatorCmd.Flags().StringVar(&tabIndicatorTabId, "tabid", "", "tab id (defaults to WAVETERM_TABID)") + tabIndicatorCmd.Flags().StringVar(&tabIndicatorColor, "color", "", "indicator color") + tabIndicatorCmd.Flags().Float64Var(&tabIndicatorPriority, "priority", 10, "indicator priority") + tabIndicatorCmd.Flags().BoolVar(&tabIndicatorClear, "clear", false, "clear the indicator") + tabIndicatorCmd.Flags().BoolVar(&tabIndicatorBeep, "beep", false, "play system bell sound") +} + +func tabIndicatorRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("tabindicator", rtnErr == nil) + }() + + fmt.Fprintf(os.Stderr, "tabindicator is deprecated, use 'wsh badge' instead\n") + + tabId := tabIndicatorTabId + if tabId == "" { + tabId = os.Getenv("WAVETERM_TABID") + } + if tabId == "" { + return fmt.Errorf("no tab id specified (use --tabid or set WAVETERM_TABID)") + } + + oref := waveobj.MakeORef(waveobj.OType_Tab, tabId) + + var eventData baseds.BadgeEvent + eventData.ORef = oref.String() + + if tabIndicatorClear { + eventData.Clear = true + } else { + icon := "bell" + if len(args) > 0 { + icon = args[0] + } + badgeId, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("generating badge id: %v", err) + } + eventData.Badge = &baseds.Badge{ + BadgeId: badgeId.String(), + Icon: icon, + Color: tabIndicatorColor, + Priority: tabIndicatorPriority, + } + } + + event := wps.WaveEvent{ + Event: wps.Event_Badge, + Scopes: []string{oref.String()}, + Data: eventData, + } + + err := wshclient.EventPublishCommand(RpcClient, event, &wshrpc.RpcOpts{NoResponse: true}) + if err != nil { + return fmt.Errorf("publishing badge event: %v", err) + } + + if tabIndicatorBeep { + err = wshclient.ElectronSystemBellCommand(RpcClient, &wshrpc.RpcOpts{Route: "electron"}) + if err != nil { + return fmt.Errorf("playing system bell: %v", err) + } + } + + if tabIndicatorClear { + fmt.Printf("tab indicator cleared\n") + } else { + fmt.Printf("tab indicator set\n") + } + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-term.go b/cmd/wsh/cmd/wshcmd-term.go new file mode 100644 index 000000000..f62e85bff --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-term.go @@ -0,0 +1,86 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/wavebase" + "github.com/s-zx/crest/pkg/waveobj" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +var termMagnified bool + +var termCmd = &cobra.Command{ + Use: "term", + Short: "open a terminal in directory", + Args: cobra.RangeArgs(0, 1), + RunE: termRun, + PreRunE: preRunSetupRpcClient, +} + +func init() { + termCmd.Flags().BoolVarP(&termMagnified, "magnified", "m", false, "open view in magnified mode") + rootCmd.AddCommand(termCmd) +} + +func termRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("term", rtnErr == nil) + }() + + var cwd string + if len(args) > 0 { + cwd = args[0] + cwdExpanded, err := wavebase.ExpandHomeDir(cwd) + if err != nil { + return err + } + cwd = cwdExpanded + } else { + var err error + cwd, err = os.Getwd() + if err != nil { + return fmt.Errorf("getting current directory: %w", err) + } + } + var err error + cwd, err = filepath.Abs(cwd) + if err != nil { + return fmt.Errorf("getting absolute path: %w", err) + } + + tabId := getTabIdFromEnv() + if tabId == "" { + return fmt.Errorf("no WAVETERM_TABID env var set") + } + + createMeta := map[string]any{ + waveobj.MetaKey_View: "term", + waveobj.MetaKey_CmdCwd: cwd, + waveobj.MetaKey_Controller: "shell", + } + if RpcContext.Conn != "" { + createMeta[waveobj.MetaKey_Connection] = RpcContext.Conn + } + createBlockData := wshrpc.CommandCreateBlockData{ + TabId: tabId, + BlockDef: &waveobj.BlockDef{ + Meta: createMeta, + }, + Magnified: termMagnified, + Focused: true, + } + oref, err := wshclient.CreateBlockCommand(RpcClient, createBlockData, nil) + if err != nil { + return fmt.Errorf("creating new terminal block: %w", err) + } + WriteStdout("terminal block created: %s\n", oref) + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-termscrollback.go b/cmd/wsh/cmd/wshcmd-termscrollback.go new file mode 100644 index 000000000..4d8484fec --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-termscrollback.go @@ -0,0 +1,104 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/waveobj" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" + "github.com/s-zx/crest/pkg/wshutil" +) + +var termScrollbackCmd = &cobra.Command{ + Use: "termscrollback", + Short: "Get terminal scrollback from a terminal block", + Long: `Get the terminal scrollback from a terminal block. + +By default, retrieves all lines. You can specify line ranges or get the +output of the last command using the --lastcommand flag.`, + RunE: termScrollbackRun, + PreRunE: preRunSetupRpcClient, + DisableFlagsInUseLine: true, +} + +var ( + termScrollbackLineStart int + termScrollbackLineEnd int + termScrollbackLastCmd bool + termScrollbackOutputFile string +) + +func init() { + rootCmd.AddCommand(termScrollbackCmd) + + termScrollbackCmd.Flags().IntVar(&termScrollbackLineStart, "start", 0, "starting line number (0 = beginning)") + termScrollbackCmd.Flags().IntVar(&termScrollbackLineEnd, "end", 0, "ending line number (0 = all lines)") + termScrollbackCmd.Flags().BoolVar(&termScrollbackLastCmd, "lastcommand", false, "get output of last command (requires shell integration)") + termScrollbackCmd.Flags().StringVarP(&termScrollbackOutputFile, "output", "o", "", "write output to file instead of stdout") +} + +func termScrollbackRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("termscrollback", rtnErr == nil) + }() + + // Resolve the block argument + fullORef, err := resolveBlockArg() + if err != nil { + return err + } + + // Get block metadata to verify it's a terminal block + metaData, err := wshclient.GetMetaCommand(RpcClient, wshrpc.CommandGetMetaData{ + ORef: *fullORef, + }, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("error getting block metadata: %w", err) + } + + // Check if the block is a terminal block + viewType, ok := metaData[waveobj.MetaKey_View].(string) + if !ok || viewType != "term" { + return fmt.Errorf("block %s is not a terminal block (view type: %s)", fullORef.OID, viewType) + } + + // Make the RPC call to get scrollback + scrollbackData := wshrpc.CommandTermGetScrollbackLinesData{ + LineStart: termScrollbackLineStart, + LineEnd: termScrollbackLineEnd, + LastCommand: termScrollbackLastCmd, + } + + result, err := wshclient.TermGetScrollbackLinesCommand(RpcClient, scrollbackData, &wshrpc.RpcOpts{ + Route: wshutil.MakeFeBlockRouteId(fullORef.OID), + Timeout: 5000, + }) + if err != nil { + return fmt.Errorf("error getting terminal scrollback: %w", err) + } + + // Format the output + output := strings.Join(result.Lines, "\n") + if len(result.Lines) > 0 { + output += "\n" // Add final newline + } + + // Write to file or stdout + if termScrollbackOutputFile != "" { + err = os.WriteFile(termScrollbackOutputFile, []byte(output), 0644) + if err != nil { + return fmt.Errorf("error writing to file %s: %w", termScrollbackOutputFile, err) + } + fmt.Printf("terminal scrollback written to %s (%d lines)\n", termScrollbackOutputFile, len(result.Lines)) + } else { + fmt.Print(output) + } + + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-test.go b/cmd/wsh/cmd/wshcmd-test.go new file mode 100644 index 000000000..24d688bfb --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-test.go @@ -0,0 +1,30 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +var testCmd = &cobra.Command{ + Use: "test", + Hidden: true, + Short: "test command", + PreRunE: preRunSetupRpcClient, + RunE: runTestCmd, +} + +func init() { + rootCmd.AddCommand(testCmd) +} + +func runTestCmd(cmd *cobra.Command, args []string) error { + rtn, err := wshclient.TestMultiArgCommand(RpcClient, "testarg", 42, true, nil) + if err != nil { + return err + } + WriteStdout("%s\n", rtn) + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-token.go b/cmd/wsh/cmd/wshcmd-token.go new file mode 100644 index 000000000..55f19466d --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-token.go @@ -0,0 +1,45 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/util/shellutil" +) + +var tokenCmd = &cobra.Command{ + Use: "token [token] [shell-type]", + Short: "exchange token for shell initialization script", + RunE: tokenCmdRun, + Hidden: true, +} + +func init() { + rootCmd.AddCommand(tokenCmd) +} + +func tokenCmdRun(cmd *cobra.Command, args []string) (rtnErr error) { + if len(args) != 2 { + OutputHelpMessage(cmd) + return fmt.Errorf("wsh token requires exactly 2 arguments, got %d", len(args)) + } + tokenStr, shellType := args[0], args[1] + if tokenStr == "" || shellType == "" { + OutputHelpMessage(cmd) + return fmt.Errorf("wsh token requires non-empty arguments") + } + rtnData, err := setupRpcClientWithToken(tokenStr) + if err != nil { + return fmt.Errorf("error setting up rpc client: %w", err) + } + envScriptText, err := shellutil.EncodeEnvVarsForShell(shellType, rtnData.Env) + if err != nil { + return fmt.Errorf("error encoding env vars: %w", err) + } + WriteStdout("%s\n", envScriptText) + WriteStdout("%s\n", rtnData.InitScriptText) + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-version.go b/cmd/wsh/cmd/wshcmd-version.go new file mode 100644 index 000000000..af3cabd53 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-version.go @@ -0,0 +1,78 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/wavebase" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" + "github.com/s-zx/crest/pkg/wshutil" +) + +var versionVerbose bool +var versionJSON bool + +// versionCmd represents the version command +var versionCmd = &cobra.Command{ + Use: "version [-v] [--json]", + Short: "Print the version number of wsh", + RunE: runVersionCmd, +} + +func init() { + versionCmd.Flags().BoolVarP(&versionVerbose, "verbose", "v", false, "Display full version information") + versionCmd.Flags().BoolVar(&versionJSON, "json", false, "Output version information in JSON format") + rootCmd.AddCommand(versionCmd) +} + +func runVersionCmd(cmd *cobra.Command, args []string) error { + if !versionVerbose && !versionJSON { + WriteStdout("wsh v%s\n", wavebase.WaveVersion) + return nil + } + + err := preRunSetupRpcClient(cmd, args) + if err != nil { + return err + } + + resp, err := wshclient.WaveInfoCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return err + } + + updateChannel, err := wshclient.GetUpdateChannelCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000, Route: wshutil.ElectronRoute}) + if err != nil { + return err + } + + if versionJSON { + info := map[string]interface{}{ + "version": resp.Version, + "clientid": resp.ClientId, + "buildtime": resp.BuildTime, + "configdir": resp.ConfigDir, + "datadir": resp.DataDir, + "updatechannel": updateChannel, + } + outBArr, err := json.MarshalIndent(info, "", " ") + if err != nil { + return fmt.Errorf("formatting version info: %v", err) + } + WriteStdout("%s\n", string(outBArr)) + return nil + } + + // Default verbose text output + fmt.Printf("v%s (%s)\n", resp.Version, resp.BuildTime) + fmt.Printf("clientid: %s\n", resp.ClientId) + fmt.Printf("configdir: %s\n", resp.ConfigDir) + fmt.Printf("datadir: %s\n", resp.DataDir) + fmt.Printf("update-channel: %s\n", updateChannel) + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-view.go b/cmd/wsh/cmd/wshcmd-view.go new file mode 100644 index 000000000..87da5c079 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-view.go @@ -0,0 +1,114 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/waveobj" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +var viewMagnified bool + +var viewCmd = &cobra.Command{ + Use: "view {file|directory|URL}", + Aliases: []string{"preview", "open"}, + Short: "preview/edit a file or directory", + RunE: viewRun, + PreRunE: preRunSetupRpcClient, +} + +var editCmd = &cobra.Command{ + Use: "edit {file}", + Short: "edit a file", + RunE: viewRun, + PreRunE: preRunSetupRpcClient, +} + +func init() { + viewCmd.Flags().BoolVarP(&viewMagnified, "magnified", "m", false, "open view in magnified mode") + rootCmd.AddCommand(viewCmd) + editCmd.Flags().BoolVarP(&viewMagnified, "magnified", "m", false, "open view in magnified mode") + rootCmd.AddCommand(editCmd) +} + +func viewRun(cmd *cobra.Command, args []string) (rtnErr error) { + cmdName := cmd.Name() + defer func() { + sendActivity(cmdName, rtnErr == nil) + }() + if len(args) == 0 { + OutputHelpMessage(cmd) + return fmt.Errorf("no arguments. wsh %s requires a file or URL as an argument argument", cmdName) + } + if len(args) > 1 { + OutputHelpMessage(cmd) + return fmt.Errorf("too many arguments. wsh %s requires exactly one argument", cmdName) + } + tabId := getTabIdFromEnv() + if tabId == "" { + return fmt.Errorf("no WAVETERM_TABID env var set") + } + fileArg := args[0] + conn := RpcContext.Conn + var wshCmd *wshrpc.CommandCreateBlockData + if strings.HasPrefix(fileArg, "http://") || strings.HasPrefix(fileArg, "https://") { + wshCmd = &wshrpc.CommandCreateBlockData{ + TabId: tabId, + BlockDef: &waveobj.BlockDef{ + Meta: map[string]any{ + waveobj.MetaKey_View: "web", + waveobj.MetaKey_Url: fileArg, + }, + }, + Magnified: viewMagnified, + Focused: true, + } + } else { + absFile, err := filepath.Abs(fileArg) + if err != nil { + return fmt.Errorf("getting absolute path: %w", err) + } + absParent, err := filepath.Abs(filepath.Dir(fileArg)) + if err != nil { + return fmt.Errorf("getting absolute path of parent dir: %w", err) + } + _, err = os.Stat(absParent) + if err == fs.ErrNotExist { + return fmt.Errorf("parent directory does not exist: %q", absParent) + } + if err != nil { + return fmt.Errorf("getting file info: %w", err) + } + wshCmd = &wshrpc.CommandCreateBlockData{ + TabId: tabId, + BlockDef: &waveobj.BlockDef{ + Meta: map[string]interface{}{ + waveobj.MetaKey_View: "preview", + waveobj.MetaKey_File: absFile, + }, + }, + Magnified: viewMagnified, + Focused: true, + } + if cmdName == "edit" { + wshCmd.BlockDef.Meta[waveobj.MetaKey_Edit] = true + } + if conn != "" { + wshCmd.BlockDef.Meta[waveobj.MetaKey_Connection] = conn + } + } + _, err := wshclient.CreateBlockCommand(RpcClient, *wshCmd, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("running view command: %w", err) + } + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-wavepath.go b/cmd/wsh/cmd/wshcmd-wavepath.go new file mode 100644 index 000000000..f01192502 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-wavepath.go @@ -0,0 +1,141 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "bytes" + "fmt" + "io" + "os" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +var wavepathCmd = &cobra.Command{ + Use: "wavepath {config|data|log}", + Short: "Get paths to various waveterm files and directories", + RunE: wavepathRun, + PreRunE: preRunSetupRpcClient, +} + +func init() { + wavepathCmd.Flags().BoolP("open", "o", false, "Open the path in a new block") + wavepathCmd.Flags().BoolP("open-external", "O", false, "Open the path in the default external application") + wavepathCmd.Flags().BoolP("tail", "t", false, "Tail the last 100 lines of the log") + rootCmd.AddCommand(wavepathCmd) +} + +func wavepathRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("wavepath", rtnErr == nil) + }() + + if len(args) == 0 { + OutputHelpMessage(cmd) + return fmt.Errorf("no arguments. wsh wavepath requires a type argument (config, data, or log)") + } + if len(args) > 1 { + OutputHelpMessage(cmd) + return fmt.Errorf("too many arguments. wsh wavepath requires exactly one argument") + } + + pathType := args[0] + if pathType != "config" && pathType != "data" && pathType != "log" { + OutputHelpMessage(cmd) + return fmt.Errorf("invalid path type %q. must be one of: config, data, log", pathType) + } + + tail, _ := cmd.Flags().GetBool("tail") + if tail && pathType != "log" { + return fmt.Errorf("--tail can only be used with the log path type") + } + + open, _ := cmd.Flags().GetBool("open") + openExternal, _ := cmd.Flags().GetBool("open-external") + + tabId := getTabIdFromEnv() + if tabId == "" { + return fmt.Errorf("no WAVETERM_TABID env var set") + } + + path, err := wshclient.PathCommand(RpcClient, wshrpc.PathCommandData{ + PathType: pathType, + Open: open, + OpenExternal: openExternal, + TabId: tabId, + }, nil) + if err != nil { + return fmt.Errorf("getting path: %w", err) + } + + if tail && pathType == "log" { + err = tailLogFile(path) + if err != nil { + return fmt.Errorf("tailing log file: %w", err) + } + return nil + } + + WriteStdout("%s\n", path) + return nil +} + +func tailLogFile(path string) error { + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("opening log file: %w", err) + } + defer file.Close() + + // Get file size + stat, err := file.Stat() + if err != nil { + return fmt.Errorf("getting file stats: %w", err) + } + + // Read last 16KB or whole file if smaller + readSize := int64(16 * 1024) + var offset int64 + if stat.Size() > readSize { + offset = stat.Size() - readSize + } + + _, err = file.Seek(offset, 0) + if err != nil { + return fmt.Errorf("seeking file: %w", err) + } + + buf := make([]byte, readSize) + n, err := file.Read(buf) + if err != nil && err != io.EOF { + return fmt.Errorf("reading file: %w", err) + } + buf = buf[:n] + + // Skip partial line at start if we're not at beginning of file + if offset > 0 { + idx := bytes.IndexByte(buf, '\n') + if idx >= 0 { + buf = buf[idx+1:] + } + } + + // Split into lines + lines := bytes.Split(buf, []byte{'\n'}) + + // Take last 100 lines if we have more + startIdx := 0 + if len(lines) > 100 { + startIdx = len(lines) - 100 + } + + // Print lines + for _, line := range lines[startIdx:] { + WriteStdout("%s\n", string(line)) + } + + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-web.go b/cmd/wsh/cmd/wshcmd-web.go new file mode 100644 index 000000000..57de351e7 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-web.go @@ -0,0 +1,141 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/waveobj" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" + "github.com/s-zx/crest/pkg/wshutil" +) + +var webCmd = &cobra.Command{ + Use: "web [open|get|set]", + Short: "web commands", + PersistentPreRunE: preRunSetupRpcClient, +} + +var webOpenCmd = &cobra.Command{ + Use: "open url", + Short: "open a url a web widget", + Args: cobra.ExactArgs(1), + RunE: webOpenRun, +} + +var webGetCmd = &cobra.Command{ + Use: "get [--inner] [--all] [--json] css-selector", + Short: "get the html for a css selector", + Args: cobra.ExactArgs(1), + Hidden: true, + RunE: webGetRun, +} + +var webGetInner bool +var webGetAll bool +var webGetJson bool +var webOpenMagnified bool +var webOpenReplaceBlock string + +func init() { + webOpenCmd.Flags().BoolVarP(&webOpenMagnified, "magnified", "m", false, "open view in magnified mode") + webOpenCmd.Flags().StringVarP(&webOpenReplaceBlock, "replace", "r", "", "replace block") + webCmd.AddCommand(webOpenCmd) + webGetCmd.Flags().BoolVarP(&webGetInner, "inner", "", false, "get inner html (instead of outer)") + webGetCmd.Flags().BoolVarP(&webGetAll, "all", "", false, "get all matches (querySelectorAll)") + webGetCmd.Flags().BoolVarP(&webGetJson, "json", "", false, "output as json") + webCmd.AddCommand(webGetCmd) + rootCmd.AddCommand(webCmd) +} + +func webGetRun(cmd *cobra.Command, args []string) error { + fullORef, err := resolveBlockArg() + if err != nil { + return fmt.Errorf("resolving blockid: %w", err) + } + blockInfo, err := wshclient.BlockInfoCommand(RpcClient, fullORef.OID, nil) + if err != nil { + return fmt.Errorf("getting block info: %w", err) + } + if blockInfo.Block.Meta.GetString(waveobj.MetaKey_View, "") != "web" { + return fmt.Errorf("block %s is not a web block", fullORef.OID) + } + data := wshrpc.CommandWebSelectorData{ + WorkspaceId: blockInfo.WorkspaceId, + BlockId: fullORef.OID, + TabId: blockInfo.TabId, + Selector: args[0], + Opts: &wshrpc.WebSelectorOpts{ + Inner: webGetInner, + All: webGetAll, + }, + } + output, err := wshclient.WebSelectorCommand(RpcClient, data, &wshrpc.RpcOpts{ + Route: wshutil.ElectronRoute, + Timeout: 5000, + }) + if err != nil { + return err + } + if webGetJson { + barr, err := json.MarshalIndent(output, "", " ") + if err != nil { + return fmt.Errorf("json encoding: %w", err) + } + WriteStdout("%s\n", string(barr)) + } else { + for _, item := range output { + WriteStdout("%s\n", item) + } + } + return nil +} + +func webOpenRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("web", rtnErr == nil) + }() + + var replaceBlockORef *waveobj.ORef + if webOpenReplaceBlock != "" { + var err error + replaceBlockORef, err = resolveSimpleId(webOpenReplaceBlock) + if err != nil { + return fmt.Errorf("resolving -r blockid: %w", err) + } + } + if replaceBlockORef != nil && webOpenMagnified { + return fmt.Errorf("cannot use --replace and --magnified together") + } + + tabId := getTabIdFromEnv() + if tabId == "" { + return fmt.Errorf("no WAVETERM_TABID env var set") + } + + wshCmd := wshrpc.CommandCreateBlockData{ + TabId: tabId, + BlockDef: &waveobj.BlockDef{ + Meta: map[string]any{ + waveobj.MetaKey_View: "web", + waveobj.MetaKey_Url: args[0], + }, + }, + Magnified: webOpenMagnified, + Focused: true, + } + if replaceBlockORef != nil { + wshCmd.TargetBlockId = replaceBlockORef.OID + wshCmd.TargetAction = wshrpc.CreateBlockAction_Replace + } + oref, err := wshclient.CreateBlockCommand(RpcClient, wshCmd, nil) + if err != nil { + return fmt.Errorf("creating block: %w", err) + } + WriteStdout("created block %s\n", oref) + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-workspace.go b/cmd/wsh/cmd/wshcmd-workspace.go new file mode 100644 index 000000000..b1e788709 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-workspace.go @@ -0,0 +1,51 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +var workspaceCommand = &cobra.Command{ + Use: "workspace", + Short: "Manage workspaces", + // Args: cobra.MinimumNArgs(1), +} + +func init() { + workspaceCommand.AddCommand(workspaceListCommand) + rootCmd.AddCommand(workspaceCommand) +} + +var workspaceListCommand = &cobra.Command{ + Use: "list", + Short: "List workspaces", + Run: workspaceListRun, + PreRunE: preRunSetupRpcClient, +} + +func workspaceListRun(cmd *cobra.Command, args []string) { + workspaces, err := wshclient.WorkspaceListCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + WriteStderr("Unable to list workspaces: %v\n", err) + return + } + + WriteStdout("[\n") + for i, w := range workspaces { + WriteStdout(" {\n \"windowId\": \"%s\",\n", w.WindowId) + WriteStderr(" \"workspaceId\": \"%s\",\n", w.WorkspaceData.OID) + WriteStdout(" \"name\": \"%s\",\n", w.WorkspaceData.Name) + WriteStdout(" \"icon\": \"%s\",\n", w.WorkspaceData.Icon) + WriteStdout(" \"color\": \"%s\"\n", w.WorkspaceData.Color) + if i < len(workspaces)-1 { + WriteStdout(" },\n") + } else { + WriteStdout(" }\n") + } + } + WriteStdout("]\n") +} diff --git a/cmd/wsh/cmd/wshcmd-wsl.go b/cmd/wsh/cmd/wshcmd-wsl.go new file mode 100644 index 000000000..fb63489e4 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-wsl.go @@ -0,0 +1,63 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/s-zx/crest/pkg/waveobj" + "github.com/s-zx/crest/pkg/wshrpc" + "github.com/s-zx/crest/pkg/wshrpc/wshclient" +) + +var distroName string + +var wslCmd = &cobra.Command{ + Use: "wsl [-d <distribution-name>]", + Short: "connect this terminal to a local wsl connection", + Args: cobra.NoArgs, + RunE: wslRun, + PreRunE: preRunSetupRpcClient, +} + +func init() { + wslCmd.Flags().StringVarP(&distroName, "distribution", "d", "", "Run the specified distribution") + rootCmd.AddCommand(wslCmd) +} + +func wslRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("wsl", rtnErr == nil) + }() + + var err error + if distroName == "" { + // get default distro from the host + distroName, err = wshclient.WslDefaultDistroCommand(RpcClient, nil) + if err != nil { + return err + } + } + if !strings.HasPrefix(distroName, "wsl://") { + distroName = "wsl://" + distroName + } + blockId := RpcContext.BlockId + if blockId == "" { + return fmt.Errorf("cannot determine blockid (not in JWT)") + } + data := wshrpc.CommandSetMetaData{ + ORef: waveobj.MakeORef(waveobj.OType_Block, blockId), + Meta: map[string]any{ + waveobj.MetaKey_Connection: distroName, + }, + } + err = wshclient.SetMetaCommand(RpcClient, data, nil) + if err != nil { + return fmt.Errorf("setting connection in block: %w", err) + } + WriteStderr("switched connection to %q\n", distroName) + return nil +} diff --git a/cmd/wsh/main-wsh.go b/cmd/wsh/main-wsh.go new file mode 100644 index 000000000..48f3fb6d5 --- /dev/null +++ b/cmd/wsh/main-wsh.go @@ -0,0 +1,19 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "github.com/s-zx/crest/cmd/wsh/cmd" + "github.com/s-zx/crest/pkg/wavebase" +) + +// set by main-server.go +var WaveVersion = "0.0.0" +var BuildTime = "0" + +func main() { + wavebase.WaveVersion = WaveVersion + wavebase.BuildTime = BuildTime + cmd.Execute() +} diff --git a/db/db.go b/db/db.go new file mode 100644 index 000000000..311a47c9e --- /dev/null +++ b/db/db.go @@ -0,0 +1,12 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package db + +import "embed" + +//go:embed migrations-filestore/*.sql +var FilestoreMigrationFS embed.FS + +//go:embed migrations-wstore/*.sql +var WStoreMigrationFS embed.FS diff --git a/db/migrations-filestore/000001_init.down.sql b/db/migrations-filestore/000001_init.down.sql new file mode 100644 index 000000000..534c404e7 --- /dev/null +++ b/db/migrations-filestore/000001_init.down.sql @@ -0,0 +1,3 @@ +DROP TABLE db_wave_file; + +DROP TABLE db_file_data; diff --git a/db/migrations-filestore/000001_init.up.sql b/db/migrations-filestore/000001_init.up.sql new file mode 100644 index 000000000..af9fcf8c0 --- /dev/null +++ b/db/migrations-filestore/000001_init.up.sql @@ -0,0 +1,19 @@ +CREATE TABLE db_wave_file ( + zoneid varchar(36) NOT NULL, + name varchar(200) NOT NULL, + size bigint NOT NULL, + createdts bigint NOT NULL, + modts bigint NOT NULL, + opts json NOT NULL, + meta json NOT NULL, + PRIMARY KEY (zoneid, name) +); + +CREATE TABLE db_file_data ( + zoneid varchar(36) NOT NULL, + name varchar(200) NOT NULL, + partidx int NOT NULL, + data blob NOT NULL, + PRIMARY KEY(zoneid, name, partidx) +); + diff --git a/db/migrations-wstore/000001_init.down.sql b/db/migrations-wstore/000001_init.down.sql new file mode 100644 index 000000000..177ce0860 --- /dev/null +++ b/db/migrations-wstore/000001_init.down.sql @@ -0,0 +1,7 @@ +DROP TABLE db_client; + +DROP TABLE db_workspace; + +DROP TABLE db_tab; + +DROP TABLE db_block; diff --git a/db/migrations-wstore/000001_init.up.sql b/db/migrations-wstore/000001_init.up.sql new file mode 100644 index 000000000..34c3b8832 --- /dev/null +++ b/db/migrations-wstore/000001_init.up.sql @@ -0,0 +1,30 @@ +CREATE TABLE db_client ( + oid varchar(36) PRIMARY KEY, + version int NOT NULL, + data json NOT NULL +); + +CREATE TABLE db_window ( + oid varchar(36) PRIMARY KEY, + version int NOT NULL, + data json NOT NULL +); + +CREATE TABLE db_workspace ( + oid varchar(36) PRIMARY KEY, + version int NOT NULL, + data json NOT NULL +); + +CREATE TABLE db_tab ( + oid varchar(36) PRIMARY KEY, + version int NOT NULL, + data json NOT NULL +); + +CREATE TABLE db_block ( + oid varchar(36) PRIMARY KEY, + version int NOT NULL, + data json NOT NULL +); + diff --git a/db/migrations-wstore/000002_init.down.sql b/db/migrations-wstore/000002_init.down.sql new file mode 100644 index 000000000..f7ef05cd1 --- /dev/null +++ b/db/migrations-wstore/000002_init.down.sql @@ -0,0 +1 @@ +DROP TABLE db_layout; diff --git a/db/migrations-wstore/000002_init.up.sql b/db/migrations-wstore/000002_init.up.sql new file mode 100644 index 000000000..b0a05456c --- /dev/null +++ b/db/migrations-wstore/000002_init.up.sql @@ -0,0 +1,5 @@ +CREATE TABLE db_layout ( + oid varchar(36) PRIMARY KEY, + version int NOT NULL, + data json NOT NULL +); diff --git a/db/migrations-wstore/000003_activity.down.sql b/db/migrations-wstore/000003_activity.down.sql new file mode 100644 index 000000000..f355a3156 --- /dev/null +++ b/db/migrations-wstore/000003_activity.down.sql @@ -0,0 +1 @@ +DROP TABLE db_activity; \ No newline at end of file diff --git a/db/migrations-wstore/000003_activity.up.sql b/db/migrations-wstore/000003_activity.up.sql new file mode 100644 index 000000000..142922bac --- /dev/null +++ b/db/migrations-wstore/000003_activity.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE db_activity ( + day varchar(20) PRIMARY KEY, + uploaded boolean NOT NULL, + tdata json NOT NULL, + tzname varchar(50) NOT NULL, + tzoffset int NOT NULL, + clientversion varchar(20) NOT NULL, + clientarch varchar(20) NOT NULL, + buildtime varchar(20) NOT NULL DEFAULT '-', + osrelease varchar(20) NOT NULL DEFAULT '-' +); \ No newline at end of file diff --git a/db/migrations-wstore/000004_history.down.sql b/db/migrations-wstore/000004_history.down.sql new file mode 100644 index 000000000..556e9b40a --- /dev/null +++ b/db/migrations-wstore/000004_history.down.sql @@ -0,0 +1 @@ +DROP TABLE history_migrated; \ No newline at end of file diff --git a/db/migrations-wstore/000004_history.up.sql b/db/migrations-wstore/000004_history.up.sql new file mode 100644 index 000000000..6b0f2a684 --- /dev/null +++ b/db/migrations-wstore/000004_history.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE history_migrated ( + historyid varchar(36) PRIMARY KEY, + ts bigint NOT NULL, + remotename varchar(200) NOT NULL, + haderror boolean NOT NULL, + cmdstr text NOT NULL, + exitcode int NULL DEFAULT NULL, + durationms int NULL DEFAULT NULL +); diff --git a/db/migrations-wstore/000005_blockparent.down.sql b/db/migrations-wstore/000005_blockparent.down.sql new file mode 100644 index 000000000..5aed013ca --- /dev/null +++ b/db/migrations-wstore/000005_blockparent.down.sql @@ -0,0 +1 @@ +-- we don't need to remove parentoref \ No newline at end of file diff --git a/db/migrations-wstore/000005_blockparent.up.sql b/db/migrations-wstore/000005_blockparent.up.sql new file mode 100644 index 000000000..f81864ff8 --- /dev/null +++ b/db/migrations-wstore/000005_blockparent.up.sql @@ -0,0 +1,4 @@ +UPDATE db_block +SET data = json_set(db_block.data, '$.parentoref', 'tab:' || db_tab.oid) +FROM db_tab +WHERE db_block.oid IN (SELECT value FROM json_each(db_tab.data, '$.blockids')); diff --git a/db/migrations-wstore/000006_workspace.down.sql b/db/migrations-wstore/000006_workspace.down.sql new file mode 100644 index 000000000..25991a930 --- /dev/null +++ b/db/migrations-wstore/000006_workspace.down.sql @@ -0,0 +1,20 @@ +-- Step 1: Restore the $.activetabid field to db_window.data +UPDATE db_window +SET data = json_set( + db_window.data, + '$.activetabid', + (SELECT json_extract(db_workspace.data, '$.activetabid') + FROM db_workspace + WHERE db_workspace.oid = json_extract(db_window.data, '$.workspaceid')) +) +WHERE json_extract(data, '$.workspaceid') IN ( + SELECT oid FROM db_workspace +); + +-- Step 2: Remove the $.activetabid field from db_workspace.data +UPDATE db_workspace +SET data = json_remove(data, '$.activetabid') +WHERE oid IN ( + SELECT json_extract(db_window.data, '$.workspaceid') + FROM db_window +); diff --git a/db/migrations-wstore/000006_workspace.up.sql b/db/migrations-wstore/000006_workspace.up.sql new file mode 100644 index 000000000..a8f5f3314 --- /dev/null +++ b/db/migrations-wstore/000006_workspace.up.sql @@ -0,0 +1,18 @@ +-- Step 1: Update db_workspace.data to set the $.activetabid field +UPDATE db_workspace +SET data = json_set( + db_workspace.data, + '$.activetabid', + (SELECT json_extract(db_window.data, '$.activetabid')) +) +FROM db_window +WHERE db_workspace.oid IN ( + SELECT json_extract(db_window.data, '$.workspaceid') +); + +-- Step 2: Remove the $.activetabid field from db_window.data +UPDATE db_window +SET data = json_remove(data, '$.activetabid') +WHERE json_extract(data, '$.workspaceid') IN ( + SELECT oid FROM db_workspace +); diff --git a/db/migrations-wstore/000007_events.down.sql b/db/migrations-wstore/000007_events.down.sql new file mode 100644 index 000000000..7acba0115 --- /dev/null +++ b/db/migrations-wstore/000007_events.down.sql @@ -0,0 +1 @@ +DROP TABLE db_tevent; diff --git a/db/migrations-wstore/000007_events.up.sql b/db/migrations-wstore/000007_events.up.sql new file mode 100644 index 000000000..3c6311960 --- /dev/null +++ b/db/migrations-wstore/000007_events.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE db_tevent ( + uuid varchar(36) PRIMARY KEY, + ts int NOT NULL, + tslocal varchar(100) NOT NULL, + event varchar(50) NOT NULL, + props json NOT NULL, + uploaded boolean NOT NULL DEFAULT 0 +); \ No newline at end of file diff --git a/db/migrations-wstore/000008_aimeta.down.sql b/db/migrations-wstore/000008_aimeta.down.sql new file mode 100644 index 000000000..b654758c2 --- /dev/null +++ b/db/migrations-wstore/000008_aimeta.down.sql @@ -0,0 +1 @@ +-- presets exist in config files, and should automatically prepopulate the meta in the older code versions \ No newline at end of file diff --git a/db/migrations-wstore/000008_aimeta.up.sql b/db/migrations-wstore/000008_aimeta.up.sql new file mode 100644 index 000000000..203902ef9 --- /dev/null +++ b/db/migrations-wstore/000008_aimeta.up.sql @@ -0,0 +1,18 @@ +--- removes all ai: keys except ai:preset +UPDATE db_block +SET data = json_remove( + db_block.data, + '$.meta.ai:*', + '$.meta.ai:apitype', + '$.meta.ai:baseurl', + '$.meta.ai:apitoken', + '$.meta.ai:name', + '$.meta.ai:model', + '$.meta.ai:orgid', + '$.meta.ai:apiversion', + '$.meta.ai:maxtokens', + '$.meta.ai:timeoutms', + '$.meta.ai:fontsize', + '$.meta.ai:fixedfontsize' +) +WHERE json_extract(data, '$.meta.view') = 'waveai'; \ No newline at end of file diff --git a/db/migrations-wstore/000009_mainserver.down.sql b/db/migrations-wstore/000009_mainserver.down.sql new file mode 100644 index 000000000..1b3a3329f --- /dev/null +++ b/db/migrations-wstore/000009_mainserver.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS db_mainserver; diff --git a/db/migrations-wstore/000009_mainserver.up.sql b/db/migrations-wstore/000009_mainserver.up.sql new file mode 100644 index 000000000..f02556536 --- /dev/null +++ b/db/migrations-wstore/000009_mainserver.up.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS db_mainserver ( + oid varchar(36) PRIMARY KEY, + version int NOT NULL, + data json NOT NULL +); diff --git a/db/migrations-wstore/000010_merge_pinned_tabs.down.sql b/db/migrations-wstore/000010_merge_pinned_tabs.down.sql new file mode 100644 index 000000000..5b469ce8c --- /dev/null +++ b/db/migrations-wstore/000010_merge_pinned_tabs.down.sql @@ -0,0 +1,2 @@ +-- This migration cannot be reversed as pinned tab state is lost +-- during the merge operation diff --git a/db/migrations-wstore/000010_merge_pinned_tabs.up.sql b/db/migrations-wstore/000010_merge_pinned_tabs.up.sql new file mode 100644 index 000000000..8091edc7c --- /dev/null +++ b/db/migrations-wstore/000010_merge_pinned_tabs.up.sql @@ -0,0 +1,23 @@ +-- Merge PinnedTabIds into TabIds, preserving tab order +UPDATE db_workspace +SET data = json_set( + data, + '$.tabids', + ( + SELECT json_group_array(value) + FROM ( + SELECT value, 0 AS src, CAST(key AS INT) AS k + FROM json_each(data, '$.pinnedtabids') + UNION ALL + SELECT value, 1 AS src, CAST(key AS INT) AS k + FROM json_each(data, '$.tabids') + ORDER BY src, k + ) + ) +) +WHERE json_type(data, '$.pinnedtabids') = 'array' + AND json_array_length(data, '$.pinnedtabids') > 0; + +UPDATE db_workspace +SET data = json_remove(data, '$.pinnedtabids') +WHERE json_type(data, '$.pinnedtabids') IS NOT NULL; diff --git a/db/migrations-wstore/000011_job.down.sql b/db/migrations-wstore/000011_job.down.sql new file mode 100644 index 000000000..34620c17a --- /dev/null +++ b/db/migrations-wstore/000011_job.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS db_job; diff --git a/db/migrations-wstore/000011_job.up.sql b/db/migrations-wstore/000011_job.up.sql new file mode 100644 index 000000000..3b032507b --- /dev/null +++ b/db/migrations-wstore/000011_job.up.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS db_job ( + oid varchar(36) PRIMARY KEY, + version int NOT NULL, + data json NOT NULL +); diff --git a/db/migrations-wstore/000012_cmdblock.down.sql b/db/migrations-wstore/000012_cmdblock.down.sql new file mode 100644 index 000000000..d0fbdb365 --- /dev/null +++ b/db/migrations-wstore/000012_cmdblock.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_cmdblock_state; +DROP INDEX IF EXISTS idx_cmdblock_blockid_seq; +DROP TABLE IF EXISTS db_cmdblock; diff --git a/db/migrations-wstore/000012_cmdblock.up.sql b/db/migrations-wstore/000012_cmdblock.up.sql new file mode 100644 index 000000000..2781c03b5 --- /dev/null +++ b/db/migrations-wstore/000012_cmdblock.up.sql @@ -0,0 +1,30 @@ +-- Crest command-block tracking. +-- +-- Each row is one "command" executed in a terminal block: from the moment the +-- shell integration emits OSC 16162;A (prompt start) to OSC 16162;D (command +-- done). The raw output bytes are NOT duplicated here — we only record offsets +-- into the parent block's existing blockfile so the frontend can render a +-- per-command view by replaying slices of that file. +CREATE TABLE db_cmdblock ( + oid TEXT PRIMARY KEY, + blockid TEXT NOT NULL, + seq INTEGER NOT NULL, + state TEXT NOT NULL, + cmd TEXT, + cwd TEXT, + shell_type TEXT, + exit_code INTEGER, + duration_ms INTEGER, + prompt_offset INTEGER NOT NULL, + cmd_offset INTEGER, + output_start_offset INTEGER, + output_end_offset INTEGER, + ts_prompt_ns INTEGER NOT NULL, + ts_cmd_ns INTEGER, + ts_done_ns INTEGER, + agent_session_id TEXT, + created_at INTEGER NOT NULL +); + +CREATE INDEX idx_cmdblock_blockid_seq ON db_cmdblock(blockid, seq); +CREATE INDEX idx_cmdblock_state ON db_cmdblock(state); diff --git a/docs/agent-architecture.md b/docs/agent-architecture.md new file mode 100644 index 000000000..fabe01319 --- /dev/null +++ b/docs/agent-architecture.md @@ -0,0 +1,636 @@ +# Crest Agent — Architecture & Design Decisions + +This document describes the design and implementation of every major feature in Crest's native coding agent. It's the entry point for new contributors. + +## Layout + +``` +pkg/agent/ ← policy: modes, tools, sandbox, checkpoints, prompts +pkg/agent/tools/ ← tool implementations (read, write, shell, browser, ...) +pkg/agent/eval/ ← golden transcript replay framework +pkg/agent/mcp/ ← MCP client (external tool servers) +pkg/aiusechat/ ← mechanism: step loop, backends, streaming, retries +pkg/aiusechat/httpretry/← retry-with-backoff HTTP client +frontend/app/view/termblocks/ ← terminal view + inline agent rendering +frontend/app/view/term/term-agent.tsx ← reusable agent UI components +``` + +**Dependency rule:** `pkg/agent` imports `pkg/aiusechat`, never the reverse. Tools cannot import the parent `agent` package. + +--- + +## 1. LLM retry with exponential backoff + +**Problem:** A single 429 or 5xx from the AI provider would kill the entire turn, losing all in-flight work. + +**Approach:** Wrap `http.Client.Do` with a custom retry layer that buffers the request body once, replays on retry, and respects `Retry-After` headers. Equal-jitter backoff to avoid thundering-herd retries from concurrent users. + +**Files:** +- `pkg/aiusechat/httpretry/httpretry.go` — `Do()`, `MakeRetryClient()` +- Wired into all 4 backends: `pkg/aiusechat/anthropic/anthropic-backend.go`, `pkg/aiusechat/openairesponses/`, `pkg/aiusechat/openaichat/`, `pkg/aiusechat/google/` + +**Data flow:** +1. Backend calls `httpretry.Do(client, req)` +2. Body is read into `[]byte` once, then re-set on each attempt +3. On 429/500/502/503/504: wait `min(initial * 2^attempt, max) Âą jitter`, retry +4. `Retry-After` header overrides the computed backoff +5. After N attempts (default 3), return last response/error + +**Trade-offs:** Buffering the body means request size is bounded by memory. Acceptable for agent traffic (LLM requests are small). Streaming uploads would not work but we don't need them. + +--- + +## 2. Step budget enforcement + +**Problem:** A confused agent could loop forever, burning tokens and never returning. + +**Approach:** Hard cap on the number of LLM calls per turn. Soft warning injected at 80% so the model can wrap up gracefully. + +**Files:** +- `pkg/aiusechat/uctypes/uctypes.go` — `MaxSteps int` field on `WaveChatOpts` +- `pkg/aiusechat/usechat.go` — `RunAIChat` checks before each step +- `pkg/agent/agent.go` — sets `DefaultMaxAgentSteps = 50` + +**Data flow:** +1. At top of step loop: if `step >= maxSteps`, return `StopKindStepBudget` +2. At 80%: prepend "you have N steps remaining" to the system prompt for that step only +3. Frontend renders the stop reason as a visible warning + +**Trade-offs:** Hard limits feel artificial but are necessary. The 80% warning gives the model a chance to summarize before the cap. + +--- + +## 3. Context compaction + +**Problem:** Long sessions accumulate tokens until the model's context window overflows. + +**Approach:** Sliding-window compaction. When the last step's input tokens exceed 80% of the configured budget, drop middle messages and keep only the first user message + last 10 messages. No AI summarization (too slow, too expensive at MVP scale). + +**Files:** +- `pkg/aiusechat/uctypes/uctypes.go` — `ContextBudget int` on `WaveChatOpts` +- `pkg/aiusechat/chatstore/chatstore.go` — `CompactMessages(chatId, keepFirst, keepLast)` +- `pkg/aiusechat/usechat.go` — checks `Usage.InputTokens > budget * 0.8` and triggers compaction + +**Data flow:** +1. After each step, read `metrics.Usage.InputTokens` +2. If over threshold: `chatstore.CompactMessages(chatId, 1, 10)` drops everything in between +3. Next step sends the truncated history to the model + +**Trade-offs:** No summarization means lost context, but the agent can re-read files via `read_text_file` if needed. Simpler than AI summarization, no extra API calls. + +--- + +## 4. Dangerous command detection + +**Problem:** Shell commands like `rm -rf /` could destroy the user's system if approval is auto-granted by mode policy. + +**Approach:** Pattern-match the command text in `ToolVerifyInput` and override `approval` to `NeedsApproval` regardless of mode policy. + +**Files:** +- `pkg/agent/tools/dangerous.go` — `IsDangerousCommand(cmd string) bool` +- `pkg/agent/tools/shell_exec.go` — `ToolVerifyInput` calls `IsDangerousCommand` and forces approval + +**Patterns covered (12):** `rm -rf`, `git push --force`, `git reset --hard`, `dd if=/of=`, `mkfs`, `chmod 777`, `:(){:|:&};:` (fork bomb), `curl ... | sh|bash`, `wget ... | sh`, `eval $(curl)`, `> /dev/sd*`, `kill -9 1`. + +**Data flow:** +1. Agent emits `shell_exec` with cmd +2. `processToolCallInternal` calls `ToolVerifyInput(input, toolUseData)` +3. `verifyShellExec` runs `IsDangerousCommand(cmd)` — if true, sets `toolUseData.Approval = NeedsApproval` +4. Step loop sees the override and prompts the user even in auto-approve mode + +**Trade-offs:** Regex patterns can be bypassed (`r''m -rf`, base64-encoded commands). Defense in depth, not a security boundary. + +--- + +## 5. Structured audit log + +**Problem:** No way to inspect what the agent did after the fact — hard to debug failures or build trust. + +**Approach:** Every tool call appends a `ToolAuditEvent` to `AIMetrics.AuditLog`. A `MetricsCallback` on `WaveChatOpts` lets external consumers (e.g. trajectory writer) persist the log. + +**Files:** +- `pkg/aiusechat/uctypes/uctypes.go` — `ToolAuditEvent`, `AIMetrics.AuditLog`, `MetricsCallback` +- `pkg/aiusechat/usechat.go` — `processToolCall` populates the event, `applyOutcome` appends +- `pkg/agent/agent.go` — `makeTrajectoryWriter(cwd, chatID)` writes JSON to `.crest-trajectories/<chatid>.json` + +**Event fields:** timestamp, chat_id, tool_name, tool_call_id, input_args (truncated to 200 chars), approval, duration_ms, outcome, error_text. + +**Data flow:** +1. Tool starts → `startTs := time.Now()` +2. Tool finishes → build `ToolAuditEvent` with elapsed time + outcome +3. Returned in `ToolCallOutcome.Audit` +4. `applyOutcome` appends to `metrics.AuditLog` +5. End of turn → `MetricsCallback(metrics)` triggers trajectory file write + +**Trade-offs:** Args truncated at 200 chars so secrets in long inputs don't leak to disk. Trade vs full reproducibility — chose privacy. + +--- + +## 6. Anthropic prompt caching + +**Problem:** System prompt + tool schemas are repeated on every step. With Claude, that's ~80% of input tokens duplicated. Anthropic charges full price unless you mark cache breakpoints. + +**Approach:** Set `cache_control: {type: "ephemeral"}` on the last system prompt block and the last tool definition. Anthropic caches everything up to those points for 5 minutes. + +**Files:** +- `pkg/aiusechat/anthropic/anthropic-convertmessage.go` — applies cache_control during request building +- `pkg/aiusechat/anthropic/anthropic-types.go` — `anthropicCachedToolDef` wrapper + +**Why a wrapper type for tools:** `ToolDefinition` is shared across all backends and shouldn't carry Anthropic-specific cache fields. The wrapper inlines tool fields + adds `cache_control` only when serializing for Anthropic. + +**Data flow:** +1. Build system prompt array +2. Mark last block with cache_control +3. Build tool array +4. Wrap last tool in `anthropicCachedToolDef` with cache_control +5. Subsequent requests within 5 min get cache hits — usage is reported in `cache_creation_input_tokens` and `cache_read_input_tokens` + +**Trade-offs:** Only Anthropic supports this. OpenAI/Google have automatic caching but no explicit control. 5-min TTL means idle sessions lose cache (acceptable). + +--- + +## 7. Parallel tool execution + +**Problem:** When the model emits 5 `read_text_file` calls in one step, running them sequentially wastes time. Each is independent and side-effect-free. + +**Approach:** Add a `Parallel bool` field to `ToolDefinition`. If ALL tools in a step have `Parallel: true` AND none need approval, run them concurrently. Otherwise sequential (preserves approval ordering). + +**Files:** +- `pkg/aiusechat/uctypes/uctypes.go` — `Parallel bool` on `ToolDefinition` +- `pkg/aiusechat/usechat.go` — `processAllToolCalls` decides serial vs parallel +- `pkg/aiusechat/usechat.go` — `processToolCall` returns `ToolCallOutcome` (immutable) for thread safety; `applyOutcome` mutates metrics on the main goroutine + +**Tools marked parallel:** `read_text_file`, `read_dir`, `get_scrollback`, `cmd_history`. (Read-only, no shared state.) + +**Data flow:** +1. `processAllToolCalls` checks: `allParallel && noApprovalNeeded`? +2. If yes: spawn `sync.WaitGroup`, one goroutine per tool, collect outcomes +3. If no: loop sequentially as before +4. Either way: apply outcomes serially on main goroutine (audit order preserved) + +**Trade-offs:** Approval prompts force serial mode (you can't parallel-prompt). Mixed parallel+serial in one step also forces serial (simpler invariant). Could be smarter but YAGNI for now. + +--- + +## 8. Diff preview + +**Problem:** Approving a write means clicking "OK" without seeing what changes. Trust requires visibility. + +**Approach:** Backend computes original + modified content for write/edit. Frontend uses `jsdiff.structuredPatch` to render unified diff with 3 lines of context. Line-level coloring (green/red/blue) inline in the approval card. + +**Files:** +- `pkg/aiusechat/tools_writefile.go` — `verifyWriteTextFileInput`, `verifyEditTextFileInput` populate `OriginalContent` and `ModifiedContent` on `UIMessageDataToolUse` +- `pkg/aiusechat/uctypes/uctypes.go` — `OriginalContent`, `ModifiedContent` fields +- `frontend/app/view/term/term-agent.tsx` — `TermAgentInlineDiff` component using jsdiff + +**Why jsdiff over Monaco:** Monaco is heavy (~5MB), overkill for a small inline diff. jsdiff is 50KB and gives us hunks with context. + +**Why jsdiff over hand-rolled Go diff:** Frontend is the right place — it's where rendering happens. The backend just sends raw content; the diff itself is computed in the browser. + +**Data flow:** +1. Tool's `ToolVerifyInput` reads existing file (or returns nil for new file) → sets `OriginalContent` +2. Same callback computes the modified content → sets `ModifiedContent` +3. Both included in `data-tooluse` SSE event sent to frontend +4. `TermAgentInlineDiff` runs `Diff.structuredPatch(filename, filename, original, modified, "", "", {context: 3})` +5. Renders hunks with `+` (green), `-` (red), `@@` (blue) lines + +**Edge cases:** +- New file (no original) → green-tinted preview +- Identical content → "No changes" badge +- File too large → diff truncated with "(N more lines)" suffix + +**Trade-offs:** Sending full file content over SSE doubles the payload for write tool calls. Acceptable for files under 100KB (the tool's max). + +--- + +## 9. Plan-to-Do handoff + +**Problem:** After `:plan` writes a plan file, the user has to manually retype the task in `:do` mode and remember the plan path. + +**Approach:** Frontend detects `write_plan` completion in `data-tooluse` events, shows an "Execute Plan" button. Click switches to `:do` mode and sends "go" trigger. Backend reads the plan file and injects it into the system prompt. + +**Files:** +- `pkg/agent/http.go` — `PostAgentMessageRequest.PlanPath`, reads file → `PlanContext` on `AgentOpts` +- `pkg/agent/agent.go` — appends `## Active Plan\n...` to system prompt when `PlanContext != ""` +- `frontend/app/view/termblocks/termblocks.tsx` — `termAgentLastPlanPath`, `executePlan()` method +- Frontend: `useEffect` scans messages for `data-tooluse` parts where `toolname === "write_plan"` and `status === "completed"` + +**Data flow:** +1. `:plan build a feature` → agent calls `write_plan` → tool sets `InputFileName = .crest-plans/feature.md` +2. SSE event with `data-tooluse {toolname: "write_plan", status: "completed", inputfilename: "..."}` reaches frontend +3. Frontend stores `termAgentLastPlanPath` +4. UI shows "Execute Plan" button after streaming completes +5. Click → `executePlan()` sets pending mode to "do", sends "go" message with `planpath` in body +6. Backend reads file, injects into system prompt for the new turn + +**Trade-offs:** Plan path is sent once per request and cleared. If the user clicks Execute multiple times, it works on the first click only. Acceptable — second click would just re-send "go" without a plan. + +--- + +## 10. Runtime model switcher + +**Problem:** Switching models requires editing `settings.json` and restarting. Want to A/B test models mid-session. + +**Approach (current — §24 ai-config refactor):** Model chip popover in the input bar reads the in-repo catalog + `~/.config/crest/ai.json` and writes the selected `{provider, model, reasoning?}` triple to `block.meta["agent:selection"]`. Each agent request resolves the selection client-side into a complete `AIConfigRequest` and posts it; the backend ingests via `aiusechat.BuildAIOptsFromConfig`. No legacy `:model` slash command. + +**Files:** +- `frontend/app/view/cmdblock/model-picker-popover.tsx` — sectioned popover +- `frontend/app/store/ai-resolver.ts` — `resolveAIConfig` (catalog + user_config → ResolvedAIConfig) +- `frontend/app/term/render/terminal-view.tsx` — derives `activeSelection` from meta + `userConfig.default`, computes `resolvedAIConfig`, threads to AgentChatHost +- `pkg/agent/http.go` — `PostAgentMessageRequest.AIConfig`, no longer reads ai:* settings +- `pkg/aiusechat/aiconfig.go` — `BuildAIOptsFromConfig` + +**Trade-offs:** `modeloverride` (the old wire-level escape hatch) still works for eval harnesses that need to override a single field without authoring a full `ai.json` — passed in the request body, overrides `aiOpts.Model` after resolution. + +**Historical (pre-2026-05):** Earlier crest had a `:model <name>` slash command + `termAgentModelOverride` state in the input bar. Replaced by the picker UI as part of §24 (the ai-config refactor); the chatstore still uses its existing model-mismatch handling — switching models mid-conversation may force a fresh chat id depending on provider compatibility, same behavior as before. + +--- + +## 11. Live token counter + +**Problem:** Users have no visibility into token usage / cost during a session. + +**Approach:** Backend sends `data-usage` SSE event after each step with cumulative input/output tokens. Frontend renders the latest counts in the agent area. + +**Files:** +- `pkg/aiusechat/usechat.go` — emits `data-usage` event in step loop +- `frontend/app/view/term/term-agent.tsx` — `TermAgentTokenCounter` component scans message parts for latest `data-usage` + +**Data flow:** +1. Step completes → backend reads `metrics.Usage.InputTokens` + `OutputTokens` +2. Sends `data-usage {input, output, model}` SSE event +3. ai-sdk pushes it as a message part of type `data-usage` on the assistant message +4. `TermAgentTokenCounter` finds the most recent `data-usage` part and renders `1,234 in / 567 out` +5. Hidden when both are 0 (free models that don't report usage) + +**Trade-offs:** No cost calculation (would need a per-model price table maintained somewhere). Tokens are the proxy. + +--- + +## 12. Inline agent blocks (Warp-style) + +**Problem:** The floating overlay covered terminal content, captured scroll, and disconnected agent text from tool results. Modal UX felt out of place in a terminal. + +**Approach:** Render agent messages as blocks inline in the termblocks timeline. Two data sources (PTY events + ai-sdk SSE) merge into one timeline atom sorted by timestamp. + +**Files:** +- `frontend/app/view/termblocks/termblocks.tsx` — `TimelineEntry` union, `timelineAtom`, `TermAgentChatProvider`, `InlineAgentUserMsg`, `InlineAgentResponse`, `syncAgentMessages()` +- `frontend/app/view/term/term-agent.tsx` — exported `TermAgentMessagePartView` (reused inline), `TermAgentChatProvider` (hosts useChat invisibly) + +**Key decision: derived atom** +```ts +timelineAtom = atom((get) => { + const cmds = get(blocksAtom).filter(visible).map(toCmdEntry); + const agent = get(agentEntriesAtom); + return [...cmds, ...agent].sort((a, b) => a.ts - b.ts); +}); +``` + +**Data flow:** +1. PTY events update `blocksAtom` (existing pipeline, unchanged) +2. `useChat` messages update via `syncAgentMessages(messages, status)` → writes `agentEntriesAtom` +3. `timelineAtom` merges and sorts +4. `TermBlocksView` maps timeline entries to either `TermBlockRow` (cmd) or `InlineAgentResponse` (agent) +5. `:` prefix in `TermBlocksInput` activates agent mode → Enter routes to `submitTermAgentPrompt` instead of PTY + +**Trade-offs:** Two data sources increase complexity vs single store. But fully unifying them would require restructuring the PTY pipeline — out of scope. The merge-by-timestamp approach is small and additive. + +--- + +## 13. File checkpointing + rewind + +**Problem:** When the agent makes wrong file changes, the user wants to undo without losing the conversation context that led there. + +**Approach:** Track every file write/edit per turn into `CheckpointStore`. `:rewind` restores files from backups (created by the existing `filebackup.MakeFileBackup`) without touching conversation history. Modeled after Claude Code's file checkpointing. + +**Files:** +- `pkg/agent/checkpoint.go` — `CheckpointStore`, `FileChange`, `RewindTo`, `RewindLast` +- `pkg/aiusechat/uctypes/uctypes.go` — `FileChangeCallback` on `WaveChatOpts`, `ToolCallOutcome.FileChanged/FileBackup/FileIsNew` +- `pkg/aiusechat/usechat.go` — `applyOutcome` calls callback when tool changed a file +- `pkg/agent/agent.go` — `makeFileChangeRecorder(chatId, messageId)` records changes per turn +- `pkg/agent/http.go` — `AgentRewindHandler` at `/api/agent-rewind` + +**Why file restore instead of message removal:** Removing messages doesn't undo file changes on disk. Restoring files keeps history intact (the user can see what was tried) while reverting the actual damage. Matches Claude Code's mental model. + +**Data flow:** +1. Tool writes/edits a file → `filebackup.MakeFileBackup(path)` returns backup path (with sibling `.json` capturing original perm + mtime) +2. Tool sets `toolUseData.WriteBackupFileName = backup`, `InputFileName = expandedPath` (absolute, tilde expanded — the path operated on) +3. `processToolCall` extracts these into `ToolCallOutcome.FileChanged/FileBackup/FileIsNew` +4. `applyOutcome` calls `FileChangeCallback(path, backup, isNew)` +5. Callback computes SHA-256 of file content (post-write), writes `FileChange{Path, BackupPath, IsNew, ContentHash}` to `CheckpointStore` +6. User types `:rewind` → POST `/api/agent-rewind` with chatId +7. `rewindToLocked` collects changes from rewound turns, **de-duplicates by path keeping the first-recorded entry per path** (so a file written twice in one turn restores to the pre-turn original, not an intermediate state); for each entry it hashes the current file and skips with a log if it doesn't match `ContentHash` (user edited externally); otherwise calls `filebackup.RestoreBackup` (which honors the original mode) or `os.Remove` for created files +8. Conversation history untouched + +**Why the per-path de-dup:** within one turn, a tool may write file A then edit it again. Backup #1 = original A, backup #2 = post-write-1 A. Naive replay applies both in order, leaving A at the post-write-1 state. Keeping only the first-recorded backup per path restores to the true original. + +**Why the content-hash guard:** `RestoreBackup` would otherwise silently overwrite manual edits the user made to the file after the agent's last write. Hash mismatch ⇒ skip + log; the user can investigate and decide whether to manually revert. + +**Memory:** `MaxCheckpointsPerChat = 100` per chat — when exceeded, oldest checkpoints drop. Prevents long sessions from bloating the in-memory store. + +**Limitations:** +- Only Write/Edit/Delete tool changes tracked. Bash commands (`rm`, `sed -i`) bypass the backup mechanism. +- Backups are temp files; if the OS clears the cache dir between sessions, rewind fails for stale checkpoints. +- Directory ops (mkdir/move) aren't undone — empty parent dirs from removed new files are left in place. +- No persistence: server restart clears the in-memory checkpoint store; backup files on disk become orphaned (cleaned by `filebackup.CleanupOldBackups` after 5 days). + +--- + +## 14. Git worktree sandboxing + +**Problem:** When the agent works on risky changes (refactor, migration), the user wants isolation from main working tree. + +**Approach:** `:worktree [name]` command creates `.crest/worktrees/<name>/` with branch `worktree-<name>` (Claude Code model). Opt-in, persistent across turns. When active, agent operations use the worktree as cwd. + +**Files:** +- `pkg/agent/sandbox.go` — `MakeWorktree`, `Worktree.HasChanges`, `Worktree.Remove`, random name generator +- `pkg/agent/http.go` — `AgentWorktreeHandler` at `/api/agent-worktree` (create/remove/status) +- `frontend/app/view/termblocks/termblocks.tsx` — `:worktree` command, `worktreePath` state, `buildTermAgentContext` returns worktree as cwd + +**Why per-session not per-task:** Per-task means creating/destroying for every `:do` invocation — too much overhead, lose changes between turns. Per-session matches user mental model (start a feature → work on it → merge or discard). + +**Data flow:** +1. User types `:worktree feature-auth` +2. Frontend POSTs `/api/agent-worktree {action: create, cwd, name: "feature-auth"}` +3. Backend: `git rev-parse --show-toplevel` → repo root, then `git worktree add .crest/worktrees/feature-auth -b worktree-feature-auth` +4. Returns `{name, path, branch}` +5. Frontend stores `worktreePath` +6. Subsequent `buildTermAgentContext()` returns `worktreePath` as cwd → agent tools operate there +7. `:worktree exit` → backend `git worktree remove --force` + `git branch -D` + +**Trade-offs:** Adds `.crest/worktrees/` to repo (user must gitignore). Doesn't auto-clean on crash (manual `git worktree prune` if needed). + +--- + +## 15. Sub-agent delegation + +**Problem:** Some tasks are scoped well-defined sub-problems (e.g. "summarize this file"). Running them in the main conversation pollutes context with their tool-call noise. + +**Approach:** `spawn_task` tool runs a child agent with isolated chat context, same model + tools, 15-step budget. Returns a completion summary to the parent. + +**Files:** +- `pkg/agent/tools/spawn_task.go` — `SpawnTask`, `SpawnTaskConfig`, `runSpawnTask` +- `pkg/agent/registry.go` — `case "spawn_task"` builds `SpawnTaskConfig` with closure pointers to `SystemPromptForMode` + `ToolsForMode` + +**Why closures in config:** `pkg/agent/tools` cannot import `pkg/agent` (would cycle). Closures inject the needed functions at registration time without breaking the dependency rule. + +**Data flow:** +1. Agent calls `spawn_task {task: "...", mode: "ask"}` +2. Tool generates new chatId, builds `WaveChatOpts` with the parent's AI config but isolated chatstore entry; subtask context is derived from parent so cancellation propagates +3. Uses `MakeDiscardSSEHandlerCh` (not `httptest.NewRecorder`) so SSE writes drain rather than fill a buffer and error out after ~10 messages +4. Calls `aiusechat.RunAIChat(ctx, sseHandler, backend, chatOpts)` directly; auto-approves all non-dangerous tools (the sub-agent has no UI) +5. Returns metrics summary (steps, tool calls, tokens) — not the actual response text (would require extracting from chatstore, complex) +6. `defer chatstore.DefaultChatStore.Delete(subChatId)` cleans up the isolated chat entry + +**Approval handling:** Sub-agent auto-approves every tool call by default. *However*, `shell_exec.ToolVerifyInput` overrides `Approval = NeedsApproval` for any command flagged by `IsDangerousCommand` (rm -rf, fork bomb, eval $(curl), etc.) — and the sub-agent has no UI to grant approval. **Dangerous commands inside a sub-agent therefore block until the 120s spawn-task timeout.** This is intentional: a sub-agent silently running `rm -rf /` would be far worse than blocking. Callers should structure sub-tasks to avoid dangerous shell commands; if you genuinely need them, run the sub-task as a foreground turn instead. + +**Trade-offs:** Returning summary instead of text means the parent agent gets a thumbs-up but not the answer. For "summarize this file" this is wrong. For "go run a long-running test" this is fine. We chose the simpler implementation; future work could extract the last assistant text from the sub-chatstore. + +--- + +## 16. Background shell_exec + +**Problem:** `npm run dev`, `python -m http.server`, etc. run forever. The agent shouldn't block on them. + +**Approach:** Add `background: true` to `shell_exec`. When true, the tool returns immediately after starting the process, without waiting for completion. The block remains visible — user can monitor it. + +**Files:** +- `pkg/agent/tools/shell_exec.go` — `shellExecInput.Background`, early-return after `ResyncController` + +**Data flow:** +1. Agent calls `shell_exec {cmd: "npm run dev", background: true}` +2. Tool creates the cmd block, queues layout, starts the controller (same as foreground) +3. With `Background: true`: skip the poll-wait loop, return `{block_id, stdout_tail: "started in background"}` +4. Block keeps running in user's tab; agent can read state later via `get_scrollback` + +**Trade-offs:** No way for the agent to kill the background process from another tool call (would need a kill_block tool). User can close the block manually. Acceptable for MVP. + +--- + +## 17. Web fetch tool + +**Problem:** Agent can't read external docs/APIs without leaving the terminal. + +**Approach:** `web_fetch {url}` tool that GETs the URL, strips HTML to text, returns up to 100KB. Available in all 3 modes. + +**Files:** +- `pkg/agent/tools/web_fetch.go` — `WebFetch`, `fetchAndExtract`, `extractText` + +**HTML extraction:** Uses `golang.org/x/net/html` tokenizer. Skips `<script>`, `<style>`, `<noscript>`, `<svg>` elements. Preserves block-level breaks (`<p>`, `<div>`, `<li>`, `<h*>`, `<tr>`, `<br>`). + +**Limits:** +- 15s timeout +- 512KB download max (LimitReader) +- 100KB output max (truncated) +- User-Agent: `Crest/1.0 (coding agent)` + +**Data flow:** +1. Agent calls `web_fetch {url: "https://docs.foo.com/api"}` +2. Validate URL has http(s) scheme +3. HTTP GET with timeout +4. If `Content-Type: text/html`: tokenize and extract text +5. Otherwise: return raw content (handles JSON, markdown, etc.) +6. Truncate to 100KB + +**Trade-offs:** No JS execution (static HTML only). Some sites are SPA-only and return empty bodies. Agent can fall back to `browser.navigate` + `browser.read_text` for those. + +--- + +## 18. Golden transcript test framework + +**Problem:** Hard to verify agent behavior without spending API credits. Hard to catch regressions. + +**Approach:** Mock `UseChatBackend` that replays recorded LLM responses against real tools running in a temp directory. Assertions check tool call sequence, final text, and file system state. + +**Files:** +- `pkg/agent/eval/types.go` — `GoldenTranscript`, `GoldenTurn`, `GoldenResponse`, `GoldenAssertions` +- `pkg/agent/eval/mock_backend.go` — `MockBackend` implementing `UseChatBackend` with response queue +- `pkg/agent/eval/replay.go` — `RunGoldenTest`, `setupWorkspace`, `checkAssertions`, `RunAllGoldenTests` +- `pkg/agent/eval/testdata/*.golden.json` — 21 test cases + +**Format:** +```json +{ + "name": "ask-read-file", + "mode": "ask", + "setup": {"files": {"hello.txt": "Hello, World!"}}, + "turns": [ + { + "user": "What's in hello.txt?", + "responses": [ + {"tool_calls": [{"name": "read_text_file", "input": {"filename": "{{CWD}}/hello.txt"}}]}, + {"text": "The file contains: \"Hello, World!\""} + ] + } + ], + "assertions": { + "tools_called": ["read_text_file"], + "final_text_contains": ["Hello, World!"] + } +} +``` + +**`{{CWD}}` substitution:** Tool inputs are JSON-traversed and `{{CWD}}` is replaced with the test's temp dir before each run. Lets transcripts work with absolute-path tools without hardcoding paths. + +**Auto-approve override:** Eval forces all `ToolApproval` callbacks to return `AutoApproved`. Otherwise mutation tools would block on user prompt. + +**Coverage (21 tests):** ask-read-file, ask-list-dir, do-write-file, do-edit-file, plan-write-plan, multi-turn flows, error recovery, web_fetch, etc. + +**Trade-offs:** Mocked LLM means we can't test prompt quality, only tool plumbing. Real agent behavior diverges from transcripts when models update. Acceptable as a regression net for tool/loop logic. + +--- + +## 19. CI workflows + +**`.github/workflows/agent-tests.yml`** — runs on PRs touching `pkg/agent/**` or `pkg/aiusechat/**`: +- `go test -race ./pkg/agent/... ./pkg/aiusechat/...` +- Golden transcripts via `TestGoldenTranscripts` +- Race detector catches concurrent map access bugs + +**`.github/workflows/harbor-nightly.yml`** — runs terminal-bench 2.0 on schedule (3:37 AM UTC daily) + manual trigger: +- Builds `wavesrv` from source +- Installs Harbor via pip +- Runs the full benchmark with configurable model + task count +- Uploads results + logs as artifacts +- Requires `ANTHROPIC_API_KEY` or `OPENROUTER_API_KEY` repo secret + +**Trade-offs:** Harbor nightly is expensive (~30 min on the full suite, real API costs). Manual trigger lets contributors run cheaper smoke tests on demand. Results aren't auto-tracked over time — would need a leaderboard repo. + +--- + +## 20. Track C — agent UI on the new term engine + +**Problem:** The term-engine migration (P1–P16 in `docs/term-engine-migration.md`) removed the legacy `view/term/term-agent.tsx` overlay and its `TermAgentChatProvider`. The Go backend kept emitting `data-tooluse` SSE events into a void; new dev sessions show shell blocks but agent output and tool-use cards never render. + +**Approach:** Add a `Block.kind` discriminator (`shell` | `agent`) so agent exchanges become first-class blocks alongside command output. Append-only positioning by call order — no timestamp re-sort — mirrors warp's `BlockList::append_item_to_blocklist` semantics (`app/src/terminal/model/blocks.rs:1074`). A new `AgentChatHost` owns ai-sdk's `useChat` hook and bridges its message stream into per-block state on `TerminalModel`, so jotai and useChat each own their natural domain. + +**Files:** +- `frontend/app/term/engine/block.ts`, `engine/types.ts`, `engine/blocks.ts` — kind discriminator + `AgentPayload` + `appendAgentBlock` factory +- `frontend/app/term/engine/block-handler.ts:110-111` — `isAgent()` gate skips ANSI dispatch for agent blocks +- `frontend/app/term/terminal-model.ts` — `agentVisible/Posture/ChatStatus/ChatId/ModelOverride/Parts` atoms + `submitAgentMessage`/`applyAgentDelta`/`applyAgentStatus`/`applyAgentParts`/`getRecentCommands` +- `frontend/app/term/render/agent-block-element.tsx` — block view (header + user msg + markdown body / parts stream) +- `frontend/app/term/render/agent-chat-host.tsx` — useChat host, request body builder, message-stream sync +- `frontend/app/term/render/block-list-element.tsx:246-262` — kind-based dispatch +- `frontend/app/term/render/terminal-view.tsx:326-339` — onSubmit picks PTY vs useChat by mode + +**Data flow:** +1. `cmdblock-input` submits with mode resolved to "agent" (explicit pick or NLD verdict). +2. `TerminalView.onSubmit` calls the `submit` fn exposed via `AgentChatHost.onReady`. +3. `submit` mints exchangeId, calls `model.submitAgentMessage(text)` to append an agent block, then `useChat.sendMessage(text, {messageId: exchangeId})`. +4. useChat POSTs `/api/post-agent-message` and streams; a sync `useEffect` walks messages, finds the user-message id as exchangeId, calls `applyAgentParts` / `applyAgentText`. +5. `AgentBlockElement` re-renders via revision bump; tool-use parts route to `ToolUseCard`. + +**Trade-offs:** Append-only positioning means an agent block created during a still-running shell command appears below it (matches warp). Engine changes are pure additions — the shell-block path is untouched, so regression risk is contained to the kind-discriminator dispatch. + +--- + +## 21. Typed citations + chip rendering + +**Problem:** Agents emit commands and answers, but users can't easily verify what those are based on. A command suggestion may come from a stale README or an irrelevant blog post; the user should be able to click the source. + +**Approach:** A `Citation` value attached to tool-use records, rendered as clickable chips beneath each tool-use card. Mirrors warp's `AIAgentCitation` enum (`crates/ai/src/agent/citation.rs:5-11`) but adds `file` and `history` kinds with `LineStart`/`LineEnd` to cover crest's `search` and `cmd_history` tools (warp's three-kind enum doesn't cover those use cases — recorded in `docs/warp-agent-improvement-plan.md` → Audit C-class decisions). + +**Files:** +- `pkg/aiusechat/uctypes/uctypes.go` — `Citation` struct + `CitationKind_*` consts + `AddCitation` helper with dedup; mirrored field on `UIMessageDataToolUse` and `ToolAuditEvent` +- `pkg/aiusechat/usechat.go` — finalize copies `toolusedata.Citations` into the per-call audit row; new fast path so `ToolAnyCallback` returning a plain string passes through raw (no JSON quoting) +- `pkg/agent/tools/web_fetch.go`, `search.go`, `cmd_history.go` — each populates citations during the callback +- `frontend/app/store/aitypes.ts` — TS mirror of Citation + tooluse field +- `frontend/app/term/render/citation-chips.tsx` — chip render + per-kind click dispatch (web → openExternal, file → onFileJump, history → clipboard, doc → openExternal) +- `frontend/app/term/render/tool-use-card.tsx` — chips at card bottom + +**Data flow:** +1. Tool callback emits citations via `data.AddCitation({kind, url, title, linestart, lineend})`. +2. usechat finalize copies them into the audit row for trajectory replay. +3. SSE `data-tooluse` event carries them to the FE. +4. `CitationChips` renders one button per entry; click dispatches per kind. + +**Trade-offs:** The `web_fetch` and `search` tools migrated from `ToolTextCallback` to `ToolAnyCallback` so they can write into `*UIMessageDataToolUse`; the dispatcher patch ensures plain-string outputs aren't accidentally JSON-quoted. File-jump click currently copies `path:line` to clipboard rather than scrolling to a block — proper scroll-to-block needs a filename→block index and is a P0.7 carry-over. + +--- + +## 22. `ask_user_question` tool + +**Problem:** Agents at a genuine fork ("did you mean dev or main?", "framework X or Y?") otherwise guess and waste turns on retracted choices, or apologize in prose. Neither is as good as just asking once with explicit options. + +**Approach:** A first-class tool that pauses the turn with a multi-choice card. Schema and data shape mirror warp's `AskUserQuestionItem` (`crates/ai/src/agent/action/mod.rs:611-657`): up to four questions per call, each with a nested `questiontype` discriminator (today only `multiplechoice`); 2–4 options each; optional `supportsother` for free-form fallback; `recommended` flag highlights the agent's default. The user's answers ride back through the existing approval RPC. + +**Files:** +- `pkg/aiusechat/uctypes/uctypes.go` — `AskUserQuestion{Option, Type, Item, Payload, Answer}` types +- `pkg/aiusechat/toolapproval.go` — `ApprovalRequest.askAnswers` + `UpdateToolApprovalWithAnswers` + `WaitForToolApproval` returns `(approval, answers, err)` +- `pkg/aiusechat/usechat.go:386-404` — dispatcher copies answers onto `toolusedata.AskAnswers` before the tool callback runs +- `pkg/wshrpc/wshrpctypes.go` — `CommandWaveAIToolApproveData.AskAnswers` +- `pkg/wshrpc/wshserver/wshserver.go:1310` — routes through `UpdateToolApprovalWithAnswers` +- `pkg/agent/tools/ask_user_question.go` — tool definition, nested schema, strict parser +- `pkg/agent/registry.go` + `profile.go` — registered in ask / plan / do modes (not bench — bench expects deterministic non-interactive runs) +- `frontend/app/term/render/tool-ask-card.tsx` — interactive card (1–9 select, ← → between questions, ⌘â†ĩ submit, Esc cancel) + `ToolAskSummary` read-only render for resolved state +- `frontend/app/term/render/tool-use-card.tsx` — dispatch by `needsApproval` between interactive card and summary + +**Data flow:** +1. Agent emits `ask_user_question`; `ToolVerifyInput` populates `toolusedata.AskQuestion`. +2. FE renders `ToolAskCard`; user picks options. +3. User submits via `WaveAIToolApproveCommand({approval: "user-approved", askanswers: [...]})`. +4. Server calls `UpdateToolApprovalWithAnswers`; approval loop wakes; answers copy onto `toolusedata.AskAnswers`. +5. Tool callback formats answers as `{ answers: [...] }` JSON for the agent's next turn. +6. After resolution the card switches to `ToolAskSummary` (read-only chips). + +**Trade-offs:** Keyboard left/right matches warp's `ask_user_question_view.rs:1400-1401`. Cancel binding is Esc rather than warp's Ctrl-C (`ask_user_question_view.rs:759`) because Ctrl-C in Electron is reserved for the surrounding shell. The system prompt explicitly tells the agent "use only on genuine forks" — without that, models love asking permission for trivia. + +--- + +## 23. Long-running command tools (read / write / transfer) + +**Problem:** `shell_exec(background: true)` lets the agent detach from a long-running process (dev server, watcher), but the agent then has no way to follow up — look at the log, send input, hand off when the process needs human attention. + +**Approach:** Three new tools that operate on a background block by its `block_id`. Strict ports of warp's `ReadShellCommandOutput`, `WriteToLongRunningShellCommand`, and `TransferShellCommandControlToUser` actions (`crates/ai/src/agent/action/mod.rs:126-129, 61-65, 161-165`). The write tool's three modes (`raw` / `line` / `block`) match warp's `AIAgentPtyWriteMode` byte decoration (`action/mod.rs:762-812`): raw passes through, line wraps with SOH + LF for readline-style editors, block wraps in bracketed-paste markers. + +**Files:** +- `pkg/agent/tools/long_running_read.go` — tail + nested `delay` (`{kind:"duration", duration_ms}` or `{kind:"oncompletion"}`) mirroring warp's `ShellCommandDelay::{Duration, OnCompletion}` enum +- `pkg/agent/tools/long_running_write.go` — `decorateLongRunningWriteBytes` matches warp's `decorate_bytes`; `SendInput` delivers to `blockcontroller`'s input pipe +- `pkg/agent/tools/transfer_to_user.go` — `wcore.QueueLayoutActionForTab` makes a hidden background block visible; agent is expected to stop driving it on subsequent turns +- `pkg/agent/tools/shell_exec.go` — `readBlockTail` refactored into `readBlockTailN(n)` + new `readBlockTotalBytes` so long_running_read shares the same filestore path +- `pkg/agent/registry.go` + `profile.go` — registered in do (all three) + bench (read + write; transfer excluded since bench expects non-interactive runs) +- `frontend/app/view/cmdblock/cmdblock-status.tsx` — exported `AgentWatchingBadge` + `TakeOverButton` scaffolding (not yet mounted into block headers — wiring deferred to a separate design pass) + +**Data flow (typical dev-server workflow):** +1. Agent: `shell_exec({cmd: "npm run dev", background: true})` → returns `block_id`. +2. Agent: `long_running_read({block_id, delay: {kind:"duration", duration_ms: 2000}})` to give the server time to bind. +3. Reads the tail, confirms "Local: http://localhost:5173"; moves on to verification work. +4. If an interactive prompt appears: `long_running_write({block_id, input: "y", mode: "line"})`. +5. If the user is needed (OTP, auth prompt): `transfer_to_user({block_id, reason: "needs the GitHub OTP"})` — hidden block becomes visible; agent stops driving. + +**Trade-offs:** `tail_bytes` cap is a crest extension over warp (warp's agent doesn't have an LLM context window to worry about). An earlier prototype of `long_running_write` exposed signal-sending (`mode: "control"` + `signal`); audit pass removed it — warp's `WriteToLongRunningShellCommand` doesn't carry signals, and exposing kill capability through the same tool without a dedicated approval gate is the wrong default. If we need it later, a separate `signal_block` tool with always-NeedsApproval is the right shape. `transfer_to_user` takes `block_id` explicitly because crest's Go tool system has no implicit "current block" session state the way warp's runtime tracks it. + +--- + +## 24. AI configuration: catalog + user config + resolver + +**Problem:** Earlier crest had two parallel AI config systems running side-by-side — legacy `ai:*` global settings (read by `pkg/agent/http.go:buildAIOptsFromSettings`) and a new `waveai@*` mode dict (read by `pkg/aiusechat/usechat-mode.go:getWaveAISettings`). The agent HTTP handler picked one or the other based on whether the request carried `aimode`. Three overlapping layers (`settings.json`, `waveai.json`, `presets.json:ai@*`), no single source of truth, API tokens duplicated per mode, provider knowledge (endpoint URLs, apitype mapping) leaked into user-edited JSON. + +**Approach:** Four-layer architecture. (1) **Catalog** — static in-repo TS describing all known providers + popular models with endpoint, apitype, capabilities, context window, reasoning support. Maintained by crest contributors via PR. Lives in `frontend/app/store/ai-catalog.ts`. (2) **User config** — `~/.config/crest/ai.json` carries only what the user owns: their providers/credentials, default selection, optional saved profiles, optional custom models / custom endpoints not in the catalog. (3) **Selection** — per-pane `{provider, model, reasoning?}` triple persisted to `block.meta["agent:selection"]` by the model picker. (4) **Resolver** — `frontend/app/store/ai-resolver.ts` consumes (selection, user_config, catalog) and produces a `ResolvedAIConfig`. The frontend sends this on every agent request as the `aiconfig` field; backend's `aiusechat.BuildAIOptsFromConfig` ingests it 1:1 with no further catalog or settings lookups. + +**Files:** +- `frontend/app/store/ai-catalog.ts` — the catalog (TS source of truth) +- `frontend/app/store/ai-types.ts` — `AgentSelection`, `UserConfig`, `ResolvedAIConfig`, error types +- `frontend/app/store/ai-resolver.ts` — `resolveAIConfig(selection, userConfig, catalog)` +- `frontend/app/store/ai-user-config.ts` — jotai atom + loader for ai.json +- `frontend/app/view/cmdblock/model-picker-popover.tsx` — sectioned picker UI +- `pkg/aiusechat/uctypes/userconfig.go` — Go mirror of the ai.json schema +- `pkg/aiusechat/userconfig.go` — `ReadAIUserConfig` / `WriteAIUserConfig` + validation +- `pkg/aiusechat/aiconfig.go` — `BuildAIOptsFromConfig(req AIConfigRequest) (*AIOptsType, error)` + `secretLookup` injection point +- `pkg/waveobj/wtypemeta.go` — `MetaTSType.AgentSelection` + `AgentSelectionMeta` shape +- `pkg/wshrpc/wshrpctypes.go` — `GetAIUserConfigCommand` + `WriteAIUserConfigCommand` RPCs, `GetAIUserConfigRtnData` (status-tagged: `ok`/`missing`/`malformed`) + +**Data flow (per agent message):** +1. User edits `~/.config/crest/ai.json` → backend `wshrpc.GetAIUserConfigCommand` reads it → FE `aiUserConfigAtom` hydrates. +2. User clicks the model chip → `ModelPickerPopover` renders catalog + profiles + custom entries. +3. User picks → `block.meta["agent:selection"] = {provider, model, reasoning?}` via `ObjectService.UpdateObjectMeta`. +4. On submit, `terminal-view.tsx` reads the meta, runs `resolveAIConfig` (with catalog + userConfig) → `ResolvedAIConfig`. +5. `AgentChatHost`'s `prepareSendMessagesRequest` puts the resolved config in the request body's `aiconfig` field. +6. Backend `PostAgentMessageHandler` calls `aiusechat.BuildAIOptsFromConfig(req.AIConfig)`. Token resolution: literal `Token` > `TokenSecretName` via `secretstore.GetSecret` > empty (unauthed local endpoint). +7. `AIOptsType` flows to existing backends (`openai-responses`, `openai-chat`, `google-gemini`, `anthropic-messages`) unchanged. + +**Deletions vs the old system (Phase E of the refactor):** Removed entirely — `pkg/aiusechat/usechat-mode.go` (`resolveAIMode`, `getAIModeConfig`, `applyProviderDefaults`), `pkg/agent/http.go:buildAIOptsFromSettings`, `SettingsType.AiApiType / AiBaseURL / AiApiToken / AiApiTokenSecretName / AiModel / AiMaxTokens / AiTimeoutMs`, `AIModeConfigType`, `AIModeConfigUpdate`, `FullConfigType.WaveAIModes`, `MetaTSType.WaveAIMode`, `ObjRTInfo.WaveAIMode`, `telemetrydata.WaveAIMode`, `AIOptsType.AIMode`, `uctypes.AIModeQuick/Balanced/Deep/Builder*` constants, `wshserver.GetWaveAIModeConfigCommand`, `frontend/app/view/waveconfig/waveaivisual.tsx`, `global-atoms.hasCustomAIPresetsAtom`, default `waveai.json` + `schema/waveai.json`. The 5 ai/waveai keys in `defaultconfig/settings.json` (`ai:preset`, `ai:model`, `ai:maxtokens`, `ai:timeoutms`, `waveai:defaultmode`, `waveai:showcloudmodes`) are also gone. + +**Trade-offs:** Catalog is static (vs warp's proto-served Oz catalog) because crest doesn't have a backend catalog service and adding one would conflict with the BYO-API-key philosophy. Trade-off: model availability isn't backend-validated; a user picking a stale model gets the upstream provider's 404 instead of an early friendly error. Update cadence: in-repo PR to `ai-catalog.ts`. Reasoning is folded into the selection triple rather than a separate axis because most "models with reasoning" are really one model + a hint, not two models. POC stage: no migration script for existing legacy configs — users re-pick after upgrading. + +**Reference:** Full design doc at [`docs/ai-config-architecture.md`](./ai-config-architecture.md). diff --git a/docs/agent-optimization-roadmap.md b/docs/agent-optimization-roadmap.md new file mode 100644 index 000000000..7a01cbfcd --- /dev/null +++ b/docs/agent-optimization-roadmap.md @@ -0,0 +1,163 @@ +# Crest Native Agent — Optimization Roadmap + +> Reference document for evolving Crest's native agent from MVP (`feat/native-agent`) to production quality. +> Companion to [`native-agent-progress.md`](./native-agent-progress.md) (what's built) — this doc covers **what's missing and why**. + +--- + +## 1. Gap Analysis (MVP → Production) + +Seven dimensions, each with concrete gaps observed in the current implementation vs. what a production-grade terminal coding agent needs. + +### 1.1 Core Capabilities + +| Gap | Current state | Target | +|-----|---------------|--------| +| Sub-agent delegation | Single monolithic loop | Spawn sub-agents for scoped tasks (explore, plan, verify) with isolated context | +| Background task execution | All tools block the step | Long-running jobs (builds, servers) run in background, agent polls / subscribes | +| Interactive clarification | Agent cannot ask the user mid-task | `ask_user` tool that pauses the loop and waits for a human reply | +| Rich file editing | `write_text_file` overwrites whole files | Search/replace, line-range, multi-hunk patch, apply-diff | +| Multi-file coordinated edits | One file at a time | Transactional batch edits with preview + rollback | +| Terminal session persistence | Each `shell_exec` is stateless | Long-lived shell sessions (env vars, cwd, activated venvs) | +| Web search | Not built in | First-class `web_search` tool, integrated with provider or external API | +| Task resumption | Conversation lost on crash | Persist step state; resume mid-task after restart | + +### 1.2 Architecture + +| Gap | Current state | Target | +|-----|---------------|--------| +| Tool plugin system | Hard-coded registry in `pkg/agent/registry.go` | Runtime-discovered plugins (beyond MCP) with manifest + schema | +| Observability | `log.Printf` scattered throughout | Structured events (JSON), trace IDs, OpenTelemetry export | +| Pluggable memory / context store | In-memory only | Abstraction over chat history backend (SQLite, file, remote) | +| Model router / fallback | One provider at a time | Route per-mode, fallback on outage, cost-aware selection | +| Tool sandboxing | `shell_exec` has direct host access | Opt-in containers / chroot / VM-per-task for `:do` | +| Streaming abstractions | Tight coupling between SSE and step loop | Clear split: model stream → event bus → frontend | + +### 1.3 Reliability + +| Gap | Current state | Target | +|-----|---------------|--------| +| Retry on LLM failure | Request fails → loop dies | Exponential backoff on 429/5xx/network with jitter, max-retry | +| Step budget | Unbounded | Configurable `max_steps` (default 50), soft warning, hard stop | +| Context compaction | Passes full history every turn | Auto-compact at 80% of model's window, summarize older turns | +| Tool timeouts | Hardcoded per-tool | User-configurable defaults + per-call override | +| Partial failure recovery | One bad tool → whole task dies | Classify errors: recoverable (retry), actionable (surface to model), fatal (abort) | +| Deterministic error messages | Raw error strings bubble up | Normalized error taxonomy the model has been trained on | +| Provider outage handling | Crashes | Graceful degrade: switch model, queue, or surface "AI down" banner | + +### 1.4 Safety + +| Gap | Current state | Target | +|-----|---------------|--------| +| Permission model | Per-tool approval only | Per-tool + per-path + per-session policies, remembered choices | +| Dangerous command detection | None | Pattern list (`rm -rf /`, `git push --force`, `curl \| sh`) → force approval | +| Path allowlists | None | Optional allow/deny lists for read/write tools | +| Credential scrubbing | Logs may contain API keys / tokens | Redact known patterns in telemetry and chat export | +| Audit trail | Partial (chatstore) | Structured log of every tool call: who, what, when, approved-by, result | +| Git safety | Model can do anything | Block force-push to main/master, warn on destructive ops, never `--no-verify` without explicit user consent | +| Network egress controls | None | Optional deny-list / allow-list for tool-initiated outbound traffic | + +### 1.5 Performance + +| Gap | Current state | Target | +|-----|---------------|--------| +| Prompt caching | Not used | Enable Anthropic prompt cache on system prompt + stable history prefix | +| Parallel tool execution | Sequential | Run independent tool calls concurrently (same step) | +| Streaming tool output | Tool completes → result sent | Incremental stream to frontend (and optionally to model on long runs) | +| Token / cost accounting | Not tracked per session | Live counter surfaced in UI, persisted per conversation | +| Model selection by task | Always same model | Cheap model for routing/classification, strong model for synthesis | +| Context pruning | None | Truncate huge tool results (with "see full output" expansion) | + +### 1.6 Developer Experience + +| Gap | Current state | Target | +|-----|---------------|--------| +| Token / cost counter | No UI | Live in the overlay — current turn + session total | +| Plan preview | `:plan` writes markdown only | Show generated plan + "approve → switch to :do" flow | +| Diff preview | Edits apply immediately | Show unified diff before write, one-key approve/reject | +| Tool call UI | Flat list | Collapse/expand, filter by tool, search | +| Rewind / fork | None | Rewind to step N, fork a new conversation from any point | +| Runtime model switcher | Via settings only | Inline in overlay: `:model gpt-5-turbo` | +| Debug mode | None | Toggle to show raw LLM I/O, system prompt, tool schemas | + +### 1.7 Evaluation + +| Gap | Current state | Target | +|-----|---------------|--------| +| Golden transcripts | 3 | 20+ covering each tool + each mode, edge cases | +| terminal-bench 2.0 runs | Smoke-tested on 1 task | Full suite (200+ tasks), automated nightly, tracked over time | +| Tool-level micro-benchmarks | None | Per-tool pass/fail suite (mock LLM, real tool execution) | +| CI regression gate | None | Block merge on golden-suite regression | +| Trajectory visualization | Raw JSON | Viewer that replays a trajectory step-by-step with diffs | +| Latency / token benchmarks | None | Track p50/p95 per tool, per model, per task type | + +--- + +## 2. Prioritized Roadmap + +Three tiers. Tier 1 is "blocks calling this production." Tier 2 is "production-grade UX." Tier 3 is polish and stretch. + +### Tier 1 — Critical ✅ + +1. ~~**LLM retry with exponential backoff**~~ ✅ +2. ~~**Step budget enforcement**~~ ✅ +3. ~~**Context compaction at 80% threshold**~~ ✅ +4. ~~**Dangerous command detection**~~ ✅ +5. ~~**Structured audit log**~~ ✅ + +### Tier 2 — Important ✅ + +6. ~~**Prompt caching (Anthropic)**~~ ✅ +7. ~~**Parallel tool execution**~~ ✅ +8. ~~**Live token / cost counter**~~ ✅ +9. ~~**Diff preview (write + edit)**~~ ✅ — jsdiff + structuredPatch +10. ~~**Plan → Do handoff**~~ ✅ +11. ~~**Runtime model switcher**~~ ✅ +12. ~~**Expanded golden transcripts (21)**~~ ✅ + +### Bonus — Warp-Style Inline Blocks ✅ + +- ~~**Replace overlay with inline timeline**~~ ✅ — agent messages render as blocks in the terminal stream + +### Tier 3 — Polish / Stretch (open-ended) + +Goal: capabilities that separate a good agent from a great one. + +13. ~~**Sub-agent delegation**~~ ✅ — `spawn_task` tool with isolated chat context, 15-step budget, same model/tools. +14. ~~**Background task execution**~~ ✅ — `shell_exec` `background: true` returns immediately with block_id. +15. ~~**Web search / fetch tool**~~ ✅ — `web_fetch` fetches URLs, strips HTML, returns text. Available in all modes. +16. ~~**Tool sandboxing**~~ ✅ — Git worktree isolation via `:worktree` command (Claude Code model). Opt-in, persistent per session. +17. ~~**Conversation rewind**~~ ✅ — `:undo` removes last turn from both frontend and backend chatstore. +18. **Full Harbor nightly run** — all tasks, scored, trended; regression alerts. +19. ~~**CI regression gate**~~ ✅ — GitHub Actions workflow for agent tests +20. **Trajectory viewer** — replay a session step-by-step, show diffs, timing, token usage. + +--- + +## 3. Reference Coding Agents + +When working on any item above, pick one or two reference implementations to benchmark against. List below; user chooses which to study per feature. + +| Agent | Language | Source | Strengths | When to look | +|-------|----------|--------|-----------|--------------| +| **ForgeCode** | Rust | [antinomyhq/forge](https://github.com/antinomyhq/forge) | Multi-agent architecture, mode system, already our structural reference | Architecture, modes, tool registry | +| **Claude Code** | Proprietary | Anthropic (observable behavior only) | Most feature-complete terminal agent; strong UX polish | DevEx, plan/diff previews, overall feel | +| **OpenAI Codex CLI** | TypeScript | [openai/codex](https://github.com/openai/codex) | Official reference, approval model, sandboxing | Safety model, approval flows | +| **Aider** | Python | [Aider-AI/aider](https://github.com/Aider-AI/aider) | Mature, git-native, strong diff/edit semantics | File editing, diff UI, git integration | +| **OpenHands** | Python / TS | [All-Hands-AI/OpenHands](https://github.com/All-Hands-AI/OpenHands) | Full dev-agent framework, sandboxed runtime | Sub-agents, sandboxing, evaluation | +| **Goose** | Rust | [block/goose](https://github.com/block/goose) | MCP-first design, extensible providers | MCP, provider abstraction | +| **Cline** | TypeScript | [cline/cline](https://github.com/cline/cline) | Popular VS Code agent, approval UX, plan-then-act | Approval UI, plan/act mode split | + +**Suggested pairing:** +- Reliability (Tier 1) → ForgeCode + Codex CLI +- UX (Tier 2) → Claude Code + Cline +- Sandboxing / sub-agents (Tier 3) → OpenHands + Goose +- Editing / diffs → Aider + +--- + +## 4. Working Notes + +- Don't treat this doc as a contract — re-prioritize freely as we learn from real use. +- Each Tier 1 / Tier 2 item should land as its own PR with at least one golden transcript covering the new behavior. +- When adding Tier 3 items, check back here first and reconsider priority in light of whatever real pain has surfaced. diff --git a/docs/agent-runtime-architecture.md b/docs/agent-runtime-architecture.md new file mode 100644 index 000000000..910abb287 --- /dev/null +++ b/docs/agent-runtime-architecture.md @@ -0,0 +1,411 @@ +# Agent Runtime Architecture — Design Doc + +**Status:** **design locked (2026-05-23)** ¡ **Implementation in progress (tasks #8–#14)** +**Replaces:** the deleted `pkg/agent/` Go agent loop + `pkg/aiusechat/` four hand-rolled backends +**Companion docs:** +- [`ai-config-architecture.md`](./ai-config-architecture.md) — model selection, catalog, resolver (Layer 1–4 of the AI stack) +- [`ai-sdk-provider-migration-eval.md`](./ai-sdk-provider-migration-eval.md) — why Option D (agent loop in Electron main + pi) was chosen over A/B/C + +This doc captures the **runtime** layer: how a per-pane conversation runs in the Electron main process on top of pi-agent-core (`emain/agent/`) and pi-ai (`emain/ai/`), how sessions are persisted, how cwd flows through requests, how panes bind to sessions, and the cross-pane / lifecycle behavior. + +References that informed the design are inline. Major sources: +- **warp** AI / agent code at `/Users/mac/projects/warp/app/src/` (read May 2026) +- **pi-agent-core** in-tree at `emain/agent/` (started from `earendil-works/pi v0.75.5`) + +--- + +## 1. The premise + +crest used to: +- Run the agent loop in a separate Go daemon (`wavesrv`, via `pkg/agent/` + `pkg/aiusechat/`) +- Use four hand-rolled LLM backends (openaichat, openairesponses, anthropic, gemini, ~3000 LOC Go) +- Route per-pane conversations by an ephemeral chatId minted in the React renderer + +After the [migration eval](./ai-sdk-provider-migration-eval.md), the project committed to **Option D**: +- Agent loop in **Electron main** (Node), not wavesrv (which keeps PTY + block IO duties only) +- **pi-agent-core + pi-ai** as the underlying agent / LLM stack, integrated in-tree (not vendored) +- Renderer becomes a pure UI surface that talks to main via IPC + +This doc takes those decisions as given and specifies the per-pane runtime that lives on top. + +--- + +## 2. The runtime layers + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ Renderer (React) │ +│ useChat → usePiChat (task #12) │ +│ block.meta["agent:session"] holds AgentSessionMeta │ +│ block.meta["agent:selection"] holds picker selection │ +└─────────────────────────â”Ŧ──────────────────────────────────────────┘ + │ IPC (task #9) + │ - agent:send / abort / subscribe + │ - agent:list-sessions-for-cwd + │ - agent:open-session + â–ŧ +┌────────────────────────────────────────────────────────────────────┐ +│ Electron main — "the agent process" │ +│ │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ Sessions layer (emain/agent/sessions.ts) │ │ +│ │ - one JsonlSessionRepo for the whole process │ │ +│ │ - cwd-grouped JSONL files under │ │ +│ │ ~/.config/crest{-dev}/sessions/<encodedCwd>/...jsonl │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ Harness cache (per-session-path) │ │ +│ │ Map<sessionPath, PaneHarness> │ │ +│ │ Each PaneHarness wraps: │ │ +│ │ - pi AgentHarness (the actual stateful agent) │ │ +│ │ - NodeExecutionEnv (cwd, shell config — mutable) │ │ +│ │ - prompt-input state (cwd, gitBranch, recentCmds) │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ │ +│ Tools (task #10) — crest-specific TS tools, ported from │ +│ pkg/agent/tools/*.go; bash/file/web run in main process; │ +│ block/scrollback/focus call wavesrv via wshrpc. │ +│ │ +│ Permissions (task #11) — beforeToolCall hook; simple allowlist │ +│ + bench-mode bypass; no posture/rules engine. │ +└─────────────────────────â”Ŧ──────────────────────────────────────────┘ + │ stdio / HTTP to upstream LLM APIs + â–ŧ + OpenAI / Anthropic / Google / OpenRouter + (via pi-ai providers in emain/ai/providers/) +``` + +The crest-specific integration layer (`emain/agent/sessions.ts`, `build-system-prompt.ts`, `harness-factory.ts`) is intentionally **small** — targeting under 250 LOC total. The bulk of the work lives in pi (vendored as in-tree code) and we wire it to crest's pane / cwd / block model. + +--- + +## 3. Foundation choice — pi over custom + +Three alternatives were considered (see [migration eval](./ai-sdk-provider-migration-eval.md) §5–7): + +- **Option A** (FE provider plugins in renderer): rejected — API keys would leak into the renderer process. +- **Option B** (Go SDK swap, keep wavesrv): rejected — doesn't address the "wavesrv shouldn't own the agent" structural issue. +- **Option C** (BE proxy + FE providers): rejected — still has to move the agent loop to renderer. +- **Option D** (agent loop in Electron main + pi-agent-core): **chosen**. + +Of the libraries that could underlie Option D, **pi-agent-core was chosen** over building from scratch because pi gives us for free: +- Stateful agent class with typed event stream (`AgentHarness`) +- 13 LLM providers under one interface (`pi-ai`) +- Compaction + branch summarization (`harness/compaction/`) +- JSONL session storage with cwd grouping (`harness/session/jsonl-repo.ts`) +- Skill system + prompt templates (`harness/skills.ts`, `harness/prompt-templates.ts`) +- Parallel + sequential tool execution +- `beforeToolCall` / `afterToolCall` / `shouldStopAfterTurn` hooks +- Compaction triggers on context-window pressure + +The cost: bus factor 1 (Mario Zechner), mitigated by integrating in-tree so we can fork-edit any time. Trimmed providers we don't use (Bedrock / Vertex / Azure / Codex / Mistral / Cloudflare / Faux / image generation); kept openai-responses, openai-completions, anthropic, google. OpenRouter rides on openai-completions with a base URL override. + +**Posture for adding crest-specific code:** when crest's convention disagrees with pi's design, **pi's design wins by default**. crest's conventions are mostly POC-era ad-hoc choices; pi is OSS-validated and considered. Diverge from pi only with an explicit recorded reason. (Lesson learned the expensive way — see decision §7.2 below.) + +--- + +## 4. Session model + +### 4.1 The Session type + +A "session" is one continuous AI conversation thread, persisted to a JSONL file. The canonical type is **pi's `Session<JsonlSessionMetadata>`** — we do NOT wrap it. + +`JsonlSessionMetadata` shape (`emain/agent/harness/types.ts:439`): +```ts +interface JsonlSessionMetadata extends SessionMetadata { + id: string; // UUID minted by pi + createdAt: string; // ISO 8601 timestamp + cwd: string; // creation-time cwd, immutable + path: string; // absolute JSONL file path + parentSessionPath?: string; // for forks; unused in v1 +} +``` + +The JSONL file is append-mode: header on line 1, one entry per line. Each entry is an `AgentMessage`, `ModelChangeEntry`, `CompactionEntry`, etc. The file IS the authoritative session state — restart-resilient by construction. + +### 4.2 Storage layout + +``` +~/.config/crest{-dev}/sessions/ ← root, mirrors WAVETERM_CONFIG_HOME +├── --Users-mac-projects-crest--/ ← one dir per cwd (pi encodeCwd) +│ ├── 2026-05-23T14-30-12_<uuid1>.jsonl +│ ├── 2026-05-23T18-04-55_<uuid2>.jsonl +│ └── 2026-05-23T22-58-30_<uuid3>.jsonl +├── --Users-mac-projects-edgeFlow.js--/ +│ └── 2026-05-22T09-15-00_<uuid4>.jsonl +└── --Users-mac--/ ← home-dir scratch convos + └── ... +``` + +**Decision rationale** (warp says flat, pi says cwd-grouped): +- warp stores everything in one SQLite DB indexed by id, with no per-cwd discovery API — they don't surface a "recent conversations in this project" UX +- pi groups by cwd at the filesystem level; `repo.list({cwd})` is one `ls` +- crest wants the per-cwd discovery affordance (see §6.4 cross-pane behavior), so **pi's layout wins** +- Filesystem JSONL is also restart-resilient without a DB migration story + +### 4.3 Why we don't roll our own SessionStore + +An earlier task #8 iteration wrote a flat per-chatId `SessionStore`. This was a mistake — re-justified as "simpler" but actually: +- The "simpler" version cost us discovery, append-mode persistence, fork support, and metadata-without-load APIs (pi has all of these) +- `JsonlSessionRepo` already takes `NodeExecutionEnv` (already in the tree) for its FileSystem dependency — there is no "complex abstraction" to wrestle with +- The bare cwd-flat structure is exactly what we need + +Lesson: **don't write code that already exists in pi without an explicit comparison showing why.** + +--- + +## 5. Pane ↔ session binding + +### 5.1 Binding direction + +The pane → session reference is stored in **block.meta**: + +```go +// pkg/waveobj/wtypemeta.go +AgentSession *AgentSessionMeta `json:"agent:session,omitempty"` + +type AgentSessionMeta struct { + Id string `json:"id"` + CreatedAt string `json:"createdAt"` + Cwd string `json:"cwd"` + Path string `json:"path"` +} +``` + +This shape is **structurally compatible** with pi's `JsonlSessionMetadata` (subset — missing only optional `parentSessionPath` which is for fork tracking we don't yet wire). The renderer round-trips this object to/from main process IPC without translation; main passes it directly to `repo.open(meta)`. + +**Naming exception:** `createdAt` is camelCase, not crest's standard lowercase-no-underscore. Rationale in §7.2 below. + +The renderer-side picker / banner UI reads `agent:session` to know whether the pane is bound to a session. Main process keeps `Map<sessionPath, PaneHarness>` as a cache; the cache is a memory optimization, not the source of truth. + +This differs from **warp**, which stores the binding in a global `ActiveAgentViewsModel.agent_view_handles: HashMap<terminal_view_id, controller>` (`app/src/ai/active_agent_views_model.rs:88`) rather than in pane state. crest's block.meta is already a persistent per-pane store — using it is more direct. + +### 5.2 Lazy creation + +A pane has **no session by default**. `agent:session` is null until the user sends their first agent message in that pane. + +On first send: +1. IPC `agent:send` arrives at main with no session metadata +2. Main process: `repo.create({cwd: paneCurrentCwd})` mints a fresh Session +3. Main returns the new `AgentSessionMeta` to renderer +4. Renderer writes `block.meta["agent:session"] = meta` +5. Main caches the PaneHarness in `Map<path, PaneHarness>` +6. Subsequent sends in this pane reuse the same session + harness + +**Warp does the same lazy pattern** (`app/src/pane_group/mod.rs:6831–6864`) — pane open does NOT create a conversation; the first user input does. + +### 5.3 Cwd at session creation vs cwd at send time + +Two cwds matter: +- **Session.cwd** — fixed at creation time, drives storage dir and grouping. Immutable. +- **Pane's current cwd** — mutable, updates when user types `cd`. + +When the pane's cwd changes (`cd /other-proj`), the session does NOT move. The session stays grouped under its original cwd's directory. **This matches warp's semantics** (`active_session.rs` updates `current_working_directory` per block event, but `conversation.rs:1491 update_for_new_request_input` keeps the same conversation): + +> warp `controller.rs:277`: +> ```rust +> let working_directory = active_session.as_ref(app).current_working_directory().cloned(); +> ``` +> warp `conversation.rs:1529` — the cwd lands on the new **exchange** struct, not on the conversation: +> ```rust +> let new_exchange = AIAgentExchange { +> working_directory: working_directory.clone(), +> ... +> }; +> self.append_exchange_to_task(&task_id, new_exchange)?; +> ``` + +For crest, the equivalent is: rebuild the system prompt with the **current** pane cwd on each send. The session's stored cwd reflects where it was born; the LLM always sees fresh context. + +We do **NOT** record per-message cwd metadata in v1. pi's `AgentMessage` shape doesn't have a `workingDirectory` field. We could extend it via pi's `declare module` mechanism, but that's a YAGNI feature for now — the LLM gets fresh cwd via system prompt, which is 99% of warp's value. Revisit when a "show conversation history with cwd context" UX lands. + +### 5.4 Per-send cwd update mechanism + +Pi's `NodeExecutionEnv.cwd: string` is a public mutable field (`emain/agent/harness/env/nodejs.ts:218`). The PaneHarness wrapper exposes a single `update()` method: + +```ts +interface PaneHarness { + readonly harness: AgentHarness; + /** Update mutable pane state. Call before each send if anything changed. */ + update(inputs: SystemPromptInputs): void; +} +``` + +`update()` does two things: +1. Mutates `env.cwd` so tool execution targets the new directory +2. Updates the closure that `systemPrompt: () => buildSystemPrompt(...)` reads, so the next turn's prompt reflects the new cwd / gitBranch / recentCmds + +This is the only "wrapper" we add around AgentHarness, and it exists strictly to expose the env mutation seam that pi otherwise leaves implicit. It is not an "AgentRuntime" — it does not re-implement subscribe / send / abort / message storage. Those are direct AgentHarness methods. + +--- + +## 6. Pane / session lifecycle + +### 6.1 New pane opens + +The pane has no `agent:session`. UI behavior: + +1. Renderer calls `agent:list-sessions-for-cwd(paneCurrentCwd)` via IPC +2. If non-empty → render an inline banner: "You have N past conversations in this project. [Resume most recent] [Show all] [Dismiss]" +3. User picks Resume → renderer writes `block.meta["agent:session"] = meta`, subsequent sends use it +4. User picks Dismiss or just starts typing → next send creates a fresh session (see §5.2) + +This banner **exceeds warp's behavior** — warp has zero affordance for cross-pane resume (`view_components/agent_toast.rs` is for completion notifications only, not session resumption). The decision to surface it reflects an explicit product judgment that this is good UX, validated by Aider and Claude Code which do similar. + +### 6.2 First send (no session) + +See §5.2. + +### 6.3 Subsequent sends (cwd may have changed) + +1. Renderer IPC: `agent:send({sessionMetadata, text, paneCurrentState: {cwd, gitBranch, recentCmds}})` +2. Main process: `harness = harnessCache.get(sessionMetadata.path) ?? buildPaneHarness(repo.open(sessionMetadata), ...)` +3. Main: `harness.update(paneCurrentState)` — refreshes env.cwd + system prompt inputs +4. Main: `harness.harness.prompt(text)` — runs the turn +5. Events stream back via the `agent:event:${sessionPath}` IPC channel + +### 6.4 Pane closes + +- Main: drop the cached `PaneHarness` (frees in-memory state) +- Session JSONL file **stays on disk** (orphaned — eligible to resurface in the §6.1 banner for future panes in the same cwd) +- block.meta retains `agent:session` (in case the pane is restored from layout) + +Matches warp's "orphan on close" (`active_agent_views_model.rs:197-210`). + +### 6.5 App restart + +- block.meta survives in wstore (SQLite) +- Pane mounts; reads `agent:session`; if present, next send opens via `repo.open(meta)` +- Pi's `repo.open()` reads the JSONL, reconstructs the Session, message history is intact +- No additional restore plumbing needed — pi's append-mode storage + wstore's block.meta together give restart-resilience for free + +### 6.6 Home-dir / no-explicit-project conversations + +When the pane's cwd is `~/Users/mac` (or any directory), pi's `encodeCwd` produces `--Users-mac--`. No special "scratch" handling. The pane behaves identically to a project-scoped pane. + +If the user wants a true "ephemeral, no persistence" mode, that's a future feature (a setting or a `/scratch` slash command). Not v1. + +--- + +## 7. Decisions log + +Locked decisions, with the reasoning recorded so future-us doesn't relitigate them. + +### 7.1 block.meta shape + +`AgentSessionMeta = {id, createdAt, cwd, path}`, structurally a subset of pi's `JsonlSessionMetadata`. + +**Reasoning:** Storing the full pi metadata (vs just `{cwd, path}`) costs nothing and buys us (a) UI display without IO ("Session from May 22"), (b) recovery hook if path goes stale (search by id), (c) 1:1 round-trip with pi types so future enrichments are free. Subset shapes always introduce information loss at conversion boundaries; we avoid the boundary entirely. + +### 7.2 JSON naming exception — camelCase for foreign-data round-trip + +`createdAt` is camelCase in `AgentSessionMeta` even though `rules.md` says "All fields must be lowercase, without underscores". + +**Reasoning:** For data structures that round-trip into a foreign library without translation, we mirror the library's naming. The alternative (lowercase + 4-line translator) introduces a conversion boundary that adds testing surface and risk of drift. The rule lives in `CLAUDE.md` as a documented narrow exception. + +**Future:** task #15 tracks an opt-in project-wide migration to camelCase for all existing block.meta fields. Until that lands, **only round-trip data** uses camelCase; **new crest-original fields** stay lowercase to match neighbors. + +### 7.3 Use pi's `JsonlSessionRepo`, not a custom SessionStore + +Discussed in §4.3. The earlier "we'll write a tiny SessionStore" instinct was wrong; pi's repo gives discovery / append-mode / forking / metadata-without-load, all of which we want. + +### 7.4 Use pi's `AgentHarness`, not a custom AgentRuntime wrapper + +An earlier task #8 iteration wrote a 250-LOC `AgentRuntime` class that re-implemented ~80% of pi's `AgentHarness` (subscribe, prompt, abort, message storage, lazy lifecycle) — but worse, keyed by chatId instead of Session, with flat untyped subscriber sets instead of typed handlers, and zero compaction / skill / prompt-template support. + +**The wrong code was deleted. PaneHarness is a 30-LOC adapter** that exposes the env mutation seam (§5.4) and nothing more. All other behavior is direct AgentHarness usage. + +### 7.5 Session = locked-to-creation-cwd; per-send cwd via system-prompt rebuild + +§5.3, §5.4. Matches warp's pattern. Avoids the "session migrates with cwd" semantic which would be surprising, AND avoids the "every cd opens a new session" semantic which would fragment history. + +### 7.6 Cross-pane resume: banner, not auto + +§6.1. Warp does nothing; Aider / Claude Code do similar banners. The list data is free via `repo.list({cwd})`; the UX cost is small (one banner component) and the user benefit is real. + +### 7.7 Cwd metadata on individual messages: deferred + +§5.3 last paragraph. Pi's `AgentMessage` doesn't carry cwd; we get warp's "current cwd in LLM context" via system prompt rebuild, which covers the 99% case. Per-message cwd recording (warp's `Exchange.working_directory`) is a v2 feature when a history-with-cwd UX is on the roadmap. + +### 7.8 No backward compat for old `agent:chatid` + +The Phase D / E (`ai-config-architecture.md`) chatId path is being deleted entirely (tasks #12 + #13). No migration script for users who have ongoing conversations in the old wstore chatstore — POC stage, users re-start conversations. + +### 7.9 `posture` (ask/plan/do/bench) permissions engine: dropped + +See task #11 description. Pi's `beforeToolCall` hook + a per-pane "trusted tools" allowlist + a bench-mode bypass flag covers the use cases; the 1500-LOC Go posture/rules/matcher engine doesn't carry over. + +### 7.10 MCP: dropped from v1 + +No active users. `pkg/agent/mcp/` gets deleted in task #13. Can re-add via `@modelcontextprotocol/sdk` TS package on top of pi's tool API later. + +--- + +## 8. Open questions and v2 candidates + +Things explicitly out of scope for the current sprint: + +- **Session forking UX** — pi has `repo.fork(source, opts)` and tracks `parentSessionPath`. We don't surface it yet. v2 affordance: "branch this conversation from message N". +- **Per-message cwd recording** — §7.7. +- **A real "session manager" / "history browser" UI** — `repo.list({})` walks all cwds; the data exists. A side-panel that lets users browse / search / delete sessions globally is a real feature, not v1. +- **Sharing sessions across team members** — pi has no story; warp does (server-side). Out of scope. +- **Skill marketplace** — pi has skills via TS extensions. We don't expose this yet. +- **Custom system prompts per project** — pi's `harness/system-prompt.ts` supports it; crest's `buildSystemPrompt` doesn't read project-level overrides yet. + +--- + +## 9. References + +### pi source (in-tree at `emain/agent/`) +- `harness/agent-harness.ts:164` — `AgentHarness` class +- `harness/session/jsonl-repo.ts:38` — `JsonlSessionRepo` +- `harness/session/session.ts:78` — `Session<TMetadata>` class +- `harness/env/nodejs.ts:217` — `NodeExecutionEnv` (implements `ExecutionEnv` / `FileSystem`) +- `harness/types.ts:439` — `JsonlSessionMetadata` +- `harness/system-prompt.ts` — pi's built-in system prompt builder (we use our own instead) +- `harness/compaction/compaction.ts` — pi's compaction algorithm (auto-fires via `prepareNextTurn`) + +### warp source (read May 2026, at `/Users/mac/projects/warp/app/src/`) +- `ai/agent/conversation.rs:128,273,1491,1529` — `AIConversation` + `update_for_new_request_input` (per-exchange cwd) +- `ai/agent_conversations_model.rs:513` — global conversation collection +- `ai/active_agent_views_model.rs:88,142,197` — pane → controller → conversation reverse lookup +- `terminal/model/session/active_session.rs:32` — `current_working_directory` updates from block events +- `ai/blocklist/controller.rs:253,277` — `RequestInput::new_with_common_fields` reads current cwd +- `persistence/agent.rs:37,46` — SQLite persistence (flat by id, max 100) +- `pane_group/mod.rs:6831,7155` — pane lifecycle, startup cwd inheritance + +### Related crest docs +- [`ai-config-architecture.md`](./ai-config-architecture.md) — Layer 1–4 (catalog, ai.json, selection, resolver) +- [`ai-sdk-provider-migration-eval.md`](./ai-sdk-provider-migration-eval.md) — why Electron-main + pi + +--- + +## 10. Implementation status + +Tasks (cross-ref with the task list): + +- [x] **#6** — Integrate pi source into `emain/agent` + `emain/ai` (`2a4945ba`) +- [x] **#7** — Spike: prove Agent.prompt() works end-to-end (`2a4945ba`) +- [x] **#8** — Session bridge + system-prompt builder + PaneHarness factory (`6494b288`) +- [x] **#9** — IPC bridge renderer ↔ main (`ce6c9735`) +- [x] **#10** — Port crest tools — 6 of 24 (v1 baseline: read/write/multi_edit/list_dir/web_fetch/shell_exec) (`18c9a001`). 18 deferred; tracked separately. +- [x] **#11** — Simple permissions hook, allowlist + bench bypass (`aa5a8e54`) +- [x] **#12** — `usePiChat` React hook + drop `@ai-sdk/react` (`c0222e13`) +- [x] **#13** — Delete Go agent stack. Done as four sequenced commits: + - `b42f5e99` — port live `/models` listing to electron-main IPC + - `9774a614` — port `ai.json` read/write to electron-main IPC + - `e6c41d94` — delete Wave-era `aifilediff` view + previews + - `2994635c` — delete `pkg/agent/`, `pkg/aiusechat/`, 5 dead web routes, 7 wshrpc commands, `wsh ai` CLI, dead test utilities; regen Go/TS bindings +- [ ] **#14** — E2E regression across all 4 providers × representative tool calls +- [ ] **#15** — Migrate all block.meta JSON tags to camelCase (independent housekeeping) + +### Post-#13 surface + +After #13, all AI-related state and IO lives in TypeScript: +- Provider /models listing → `emain/aiconfig/list-provider-models.ts` (IPC: `ai:list-provider-models`) +- `~/.config/crest/ai.json` read/write → `emain/aiconfig/user-config.ts` (IPC: `ai:get-user-config`, `ai:write-user-config`) +- Secret resolution (`tokensecretname` → plaintext) → `emain/aiconfig/secrets.ts`, reading `secrets.enc` directly via `safeStorage` (no Go roundtrip) +- Agent loop, sessions, tools → `emain/agent/` (covered by §2–§9) + +Secret *writes* still go through the Go `SetSecretsCommand` wshrpc (general-purpose, not AI-specific — also used by waveconfig and the builder). That's deliberate; the architecture goal was "AI lives in TS", not "all of pkg/secretstore lives in TS". diff --git a/docs/agent-runtime-handoff-2026-05-24.md b/docs/agent-runtime-handoff-2026-05-24.md new file mode 100644 index 000000000..0b796bac8 --- /dev/null +++ b/docs/agent-runtime-handoff-2026-05-24.md @@ -0,0 +1,195 @@ +# Agent Runtime Migration — Overnight Handoff (2026-05-24) + +This is a status snapshot of the autonomous work I did while you slept. Read this first when you come back, then `git log` for the commit details. + +**Branch:** `main` (ahead of `origin/main` by **5 commits**, nothing pushed — you decide when to push) +**Working tree:** **clean** +**Test suite:** **171/171 passing** +**TS:** `tsc --noEmit -p tsconfig.json` — 0 errors in any of the new code (58 pre-existing errors unchanged) + +--- + +## What's done (and committed) + +| # | Task | Commit | State | +|---|---|---|---| +| #6 | Integrate pi source into emain/agent + emain/ai | `2a4945ba` | ✅ Done (pre-handoff) | +| #7 | Spike: Agent.prompt() works end-to-end | `2a4945ba` | ✅ Done (pre-handoff) | +| #8 | Per-pane session bridge (sessions / harness / system-prompt) | `6494b288` | ✅ Done (pre-handoff) | +| #9 | IPC bridge: main ↔ renderer | `ce6c9735` | ✅ **Done overnight** | +| #11 | Permissions hook (allowlist + bench bypass) | `aa5a8e54` | ✅ **Done overnight** | +| #10 | Crest-specific tools (v1 baseline) | `18c9a001` | âš ī¸ **Partial — 6 of 24 ported** | +| #12 | usePiChat hook + drop @ai-sdk/react | `a394befc` | âš ī¸ **Half — hook only, wiring deferred** | +| #13 | Delete Go agent stack | — | đŸšĢ **Blocked on #12 wiring** | +| #14 | E2E regression | — | đŸšĢ **Blocked on #12 wiring + live API keys** | +| #15 | Migrate block.meta JSON naming to camelCase | — | 📋 Deferred (independent housekeeping) | + +(Commit hashes are short-form. Use `git log --oneline -8` to verify.) + +--- + +## What I deliberately did NOT do, and why + +### Task #10 — 18 of 24 tools deferred + +**Done (6 pure-Node tools):** +- `read_file` — file IO with offset/limit +- `write_file` — overwrites + mkdir -p +- `multi_edit` — atomic, exact-string replacements +- `list_dir` — typed entries, cap on count +- `web_fetch` — Node fetch, 1 MB cap, timeout +- `shell_exec` — `/bin/sh -c`, captures stdout/stderr/exit, SIGTERM-then-SIGKILL on abort + +**Deferred (18 tools)** — all need design decisions I wasn't going to make for you: + +| Tool | Why deferred | +|---|---| +| `ask_user_question` | Needs renderer prompt UI design | +| `browser` | Needs Electron BrowserView decision + automation lib | +| `create_block`, `focus_block`, `get_scrollback`, `headless_shell_exec` | Need wavesrv state access via wshrpc; defining that bridge is a design call | +| `transfer_to_user` | Needs UI handoff semantics | +| `spawn_task` | Sub-agent — needs orchestration design | +| `search` | Bundle ripgrep? Use Node glob+readline? Different answer per platform | +| `cmd_history` | Needs access to pane's shell history (wavesrv) | +| `dangerous`, `file_tracker`, `long_running_read`, `long_running_write`, `multi_edit` (already done), `todo`, `write_plan` | Some are extensions of file IO (need decision on persistence); others are crest-specific orchestration tools | + +The 6 we shipped cover the **minimum viable agent** — read code, edit it, run shell, fetch URLs. With these, the agent can do real coding work as soon as #12 wiring lands. + +### Task #12 — Hook implemented, wiring deferred + +I wrote `frontend/app/store/use-pi-chat.ts` (290 LOC + reducer tests) but **deliberately did not replace the live useChat path**. The reason: shape mismatch between ai-sdk `UIMessage.parts[]` and pi `AgentMessage.content[]` means the wiring touches: + +- `AgentChatHost.tsx` (swap useChat for usePiChat) +- `agent-block-element.tsx` (rewrite the parts-iteration render loop) +- `terminal-model.ts` (apply* APIs may need new shape, or be deleted entirely) +- `terminal-view.tsx` (mint sessions, pass onSessionMinted, etc.) +- `package.json` (drop `@ai-sdk/react`) + +Doing all of that wrong = agent panel breaks entirely. Best done with eyes on the screen. + +**Wiring checklist** is also in the doc-comment at the top of `use-pi-chat.ts` so it can't get lost. + +### Task #13 — Delete Go agent stack + +Cannot run safely until #12 wiring is verified end-to-end. Otherwise we delete the path the renderer is still using. + +### Task #14 — E2E regression + +Needs: +1. #12 wiring done (so the new path is what's being tested) +2. Live API keys (I don't have access) +3. Manual verification with a real model in the live app + +--- + +## Architecture invariants you can rely on + +(These match `docs/agent-runtime-architecture.md` — read that doc if you haven't.) + +1. **block.meta["agent:session"]** holds `{id, createdAt, cwd, path}` — pi's JsonlSessionMetadata shape (`createdAt` camelCase, Y1 exception). On `task generate` it shows up as `AgentSessionMeta` in `frontend/types/gotypes.d.ts`. + +2. **block.meta["agent:chatid"]** is **gone** from the schema; `terminal-view.tsx` regenerates an in-memory `chatId = useMemo(crypto.randomUUID())` per pane lifetime. This is short-lived — when you do the #12 wiring, replace it with `onSessionMinted` writing `agent:session`. + +3. **Sessions live on disk** at `~/.config/crest{-dev}/sessions/<encodedCwd>/<timestamp>_<id>.jsonl`. Pi's `JsonlSessionRepo` owns the layout; we never write to those files directly. + +4. **The harness cache** (`emain/agent-ipc.ts` `harnessCache: Map<sessionPath, PaneHarness>`) is the only thing that holds live `AgentHarness` instances. Lifetime: created on first IPC `agent:send` for a session; survives renderer remount; released only on `_resetAgentIpcForTests()` (no production GC path yet — fine for v1). + +5. **Per-pane cwd** flows through `PaneHarness.update(inputs)` on every IPC `agent:send` — mutates `env.cwd` (for tool execution) AND the closure that `systemPrompt: () => buildSystemPrompt(inputs)` reads. Matches warp's "session pinned to creation cwd, exchange carries latest" pattern. + +6. **Permissions** default to `allowAll` in v1 (no UX gate). Setting `CREST_AGENT_BENCH=1` forces allowAll regardless. Sending `allowedTools: [...]` via IPC switches to enforcement mode. + +7. **Tool execution** runs in the Electron main process by default. Tools needing wavesrv state (blocks / scrollback / etc.) would go through wshrpc — none of those are wired yet (see Task #10 deferrals). + +--- + +## Files added overnight + +``` +emain/agent-ipc.ts — ipcMain handlers + harness cache (~250 LOC) +emain/agent/permissions.ts — buildPermissionsHook + isBenchMode (~80 LOC) +emain/agent/permissions.test.ts — 8 tests +emain/agent/tools/_paths.ts — expandHome / requireAbsolute helpers +emain/agent/tools/read-file.ts — read_file tool +emain/agent/tools/write-file.ts — write_file tool +emain/agent/tools/multi-edit.ts — multi_edit tool +emain/agent/tools/list-dir.ts — list_dir tool +emain/agent/tools/web-fetch.ts — web_fetch tool +emain/agent/tools/shell-exec.ts — shell_exec tool +emain/agent/tools/index.ts — getDefaultTools() + DEFAULT_TOOL_NAMES +emain/agent/tools/tools.test.ts — 29 tests (incl. loopback HTTP for web_fetch) +frontend/app/store/use-pi-chat.ts — React hook (NOT yet wired in) +frontend/app/store/use-pi-chat.test.tsx — 8 reducer tests + +(plus modifications to emain/emain-ipc.ts, emain/preload.ts, + emain/agent/harness-factory.ts, frontend/types/custom.d.ts) +``` + +## Files in their previous shape (intentionally not touched) + +``` +frontend/app/term/render/agent-chat-host.tsx — still uses ai-sdk useChat +frontend/app/term/render/agent-block-element.tsx — still consumes UIMessagePart +frontend/app/term/terminal-model.ts — apply* APIs unchanged +frontend/app/term/render/terminal-view.tsx — useMemo(crypto.randomUUID()) chatId +pkg/agent/** — Go agent stack alive +pkg/aiusechat/** — 4 hand-rolled backends alive +pkg/agent/mcp/** — MCP module alive (to be deleted) +package.json — @ai-sdk/react still a dep +``` + +--- + +## Recommended pick-up order when you come back + +**If you have ~30 min** — review and push: +- `git log --oneline -8` to see the 5 new commits +- Read this doc + `docs/agent-runtime-architecture.md` if you want the deep version +- `git push` to send to Jason-Shen2/crest +- Sleep on whether to proceed with #12 wiring + +**If you have ~1-2 hours** — start the #12 wiring: +- Open `frontend/app/store/use-pi-chat.ts`, read the module-doc + the checklist at the bottom of this doc +- Make the AgentChatHost swap first (smallest blast radius) +- Run `npx vitest run` after each step +- Stop and ask Claude when you hit the agent-block-element rewrite — that's the biggest pain point + +**If you have a full day** — finish #12 + #13: +- #12 wiring per the checklist above +- Once `task electron:quickdev` shows a working agent panel end-to-end (manually verify with one LLM provider), delete `pkg/agent/` and `pkg/aiusechat/` and the `/api/post-agent-message` route +- Then commit and update the architecture doc §10 implementation status + +**If you want to do something orthogonal** — task #15 (camelCase migration) is fully independent and can be done any time. + +--- + +## Things I changed but want to flag for your eyes + +1. **`emain/agent/tools/list-dir.ts:81`** — has a slightly hacky `void path.sep` to avoid an unused-import warning. The `path` import is for future when list-dir might join base + entry name; for now it's not used. Either: + - Leave it (current state, harmless) + - Drop the import + the `void path.sep` line + +2. **`emain/agent/tools/shell-exec.ts`** — hardcodes `/bin/sh`. Won't work on Windows. Fine for macOS-first development; needs `os.platform() === "win32"` branch before Windows ships. Documented in code. + +3. **`frontend/app/store/use-pi-chat.ts`** — defines a `PiAgentMessage` interface at the renderer boundary instead of importing `AgentMessage` from `emain/agent/types`. Renderer must not import main-process modules. This means the two types can drift; the doc-comment notes this and asks for review when wiring. + +4. **No integration test against a real LLM was run**. The spike (`emain/agent/_spike.ts`) is the closest thing — running it with `ANTHROPIC_API_KEY=... npx tsx emain/agent/_spike.ts "hi"` is the quickest sanity check that the integrated agent actually works end-to-end. I didn't run it because I'd be burning your tokens without consent. + +--- + +## What's safe to do without me + +- Read code, review commits, push to remote. +- Run the test suite (`npx vitest run`) — 171 should pass. +- Run the spike with your own API key to manually verify the agent stack works in main. +- Inspect the architecture doc (`docs/agent-runtime-architecture.md`) and disagree with anything. + +## What needs me back + +- The actual #12 wiring (high blast radius). +- Any decision about the deferred 18 tools (they need design calls). +- The #14 E2E test against live LLMs. +- The #13 deletion (depends on #12 wiring being verified). + +--- + +End of handoff. Commit count: 5. LOC delta: ~+1700 / ~−5 (mostly the architecture doc + new tool/IPC code). diff --git a/docs/agent-user-guide.md b/docs/agent-user-guide.md new file mode 100644 index 000000000..b407102d1 --- /dev/null +++ b/docs/agent-user-guide.md @@ -0,0 +1,377 @@ +# Crest Agent User Guide + +Crest includes a built-in coding agent that runs directly in your terminal. Invoke it with `:` prefix commands in the terminal input. Agent responses appear inline alongside your command blocks. + +--- + +## AI Provider Configuration + +Crest is BYO-API-key. You configure providers + credentials in **`~/.config/crest/ai.json`**. The file is required — the agent refuses to send a message until at least one provider with credentials and a `default` selection are set. + +### Minimal example + +```jsonc +{ + "providers": { + "openai": { "tokensecretname": "OPENAI_API_KEY" } + }, + "default": { + "provider": "openai", + "model": "gpt-5" + } +} +``` + +That's it. After saving, the model picker (the chip next to the input bar) populates with OpenAI's catalog models; selecting one writes the choice to the pane's meta and the next agent message uses it. + +### Where credentials live + +Two ways to provide an API key: + +1. **`tokensecretname` (preferred)** — the OS keychain holds the token under this name; crest's `secretstore` resolves it at request time. Set the keychain entry via system tools or via `crest secret set OPENAI_API_KEY sk-...` (forthcoming wsh command). + +2. **`token` (testing only)** — literal token in the JSON. Plaintext, not recommended; takes precedence over `tokensecretname` if both are set. + +### Built-in providers + +The shipped catalog (`frontend/app/store/ai-catalog.ts`) covers OpenAI, Anthropic, Google Gemini, and OpenRouter. Each entry knows its endpoint, API protocol, default token secret name, and the popular models for that provider with their context window + capabilities. You don't write any of this in `ai.json` — only credentials and the default selection. + +### Profiles (optional) + +Save named selections you switch between: + +```jsonc +{ + "providers": { + "openai": { "tokensecretname": "OPENAI_API_KEY" }, + "anthropic": { "tokensecretname": "ANTHROPIC_API_KEY" } + }, + "default": { "provider": "openai", "model": "gpt-5-mini" }, + "profiles": { + "fast": { "provider": "openai", "model": "gpt-5-mini" }, + "deepwork": { "provider": "anthropic", "model": "claude-opus-4-7", "reasoning": "high" } + } +} +``` + +Profiles render as a `★ Profiles` section at the top of the picker. Picking one writes the resolved `{provider, model, reasoning}` triple to the pane's `agent:selection` meta — the profile *name* isn't stored, so renaming or deleting a profile later doesn't break already-selected panes. + +### Custom models (not in catalog) + +Provider is in the catalog but the model isn't (newly released, etc.): + +```jsonc +{ + "providers": { "openai": { "tokensecretname": "OPENAI_API_KEY" } }, + "default": { "provider": "openai", "model": "gpt-experimental" }, + "custom_models": [ + { + "provider": "openai", + "id": "gpt-experimental", + "displayname": "GPT Experimental", + "capabilities": ["tools"], + "contextwindow": 50000 + } + ] +} +``` + +Inherits the provider's endpoint + apitype. Override per-model with `"apitypeoverride": "openai-chat"` if the model needs a different API protocol than the provider's default. + +### Custom endpoints (entirely new provider) + +For local vLLM, Together AI, Groq, LM Studio, or anywhere not in the catalog — declare the endpoint inline: + +```jsonc +{ + "providers": { + "vllm-local": { "tokensecretname": "" } // empty == no auth header + }, + "default": { "provider": "vllm-local", "model": "qwen-coder-32b" }, + "custom_endpoints": { + "vllm-local": { + "displayname": "Local vLLM", + "endpoint": "http://localhost:8000/v1/chat/completions", + "apitype": "openai-chat", + "tokensecretname": "", + "models": [ + { + "id": "qwen-coder-32b", + "displayName": "Qwen 2.5 Coder", + "capabilities": ["tools"], + "contextWindow": 128000 + } + ] + } + } +} +``` + +`apitype` values: `openai-chat`, `openai-responses`, `anthropic-messages`, `google-gemini`. Pick whichever the endpoint speaks; most non-OpenAI compatible servers are `openai-chat`. + +### Picker UI + +Open the picker by clicking the model chip at the right end of the input bar. Sections from top to bottom: + +``` +★ Profiles (your saved selections) +OpenAI (catalog models — dimmed when no key) +Anthropic +Google Gemini +OpenRouter +Custom Endpoints (your custom_endpoints entries) +``` + +Models with reasoning capability expose an inline `[low] [med] [high]` row when selected. The chip shows the picked model's display name; the choice persists on the pane's outer block until you pick something else. + +### Where reasoning goes + +Reasoning level is **part of the selection**, not the catalog. `gpt-5 + high` and `gpt-5 + low` are the same model with different effort hints — picking either updates the reasoning sub-row and the chip badge (`GPT-5 ¡ high`). Reasoning is silently dropped when the resolved model doesn't support it, so old selections survive model swaps cleanly. + +### Architecture reference + +For maintainers / contributors: see [`docs/ai-config-architecture.md`](./ai-config-architecture.md) — covers the 4-layer design (catalog / user config / selection / resolver) and the rationale for each decision. + +--- + +## Modes + +Crest's agent operates in three modes, each with different permission levels. + +### :ask — Read-Only Q&A + +Explores your codebase and answers questions without making any changes. + +``` +:ask what does the auth middleware do? +:ask where is the database connection configured? +``` + +- All read tools (file reads, directory listings, web fetches) are auto-approved. +- Cannot write files, run shell commands, or modify anything. + +### :plan — Plan Creation + +Analyzes a task and produces a step-by-step plan file. + +``` +:plan add a dark mode toggle to the settings page +:plan refactor the payment module to use the new API +``` + +- Read tools are auto-approved. +- Plan file writes (to `.crest-plans/`) are auto-approved. +- Cannot write project files or run shell commands. +- When complete, an **Execute Plan** button appears (see Plan-to-Do Handoff below). + +### :do — Full Mutation + +Performs real changes: writes files, runs commands, creates blocks. + +``` +:do add input validation to the signup form +:do fix the failing test in auth.test.ts +``` + +- Read tools are auto-approved. +- File writes, edits, and shell commands require your approval. +- MCP tools always require approval. + +--- + +## Inline Agent Blocks + +- Type `:` in the terminal input to activate agent mode. A mode badge (ask/plan/do) appears in the input area. +- Press **Escape** to clear the agent input and return to normal terminal mode. +- Agent responses render inline in the terminal block stream, not as a floating overlay or separate panel. + +--- + +## Tools + +The agent has access to the following tools. Tools marked "auto" are approved automatically in the indicated modes. Tools marked "approval" require explicit confirmation before execution. + +### File & Directory + +| Tool | Description | Approval | +|------|-------------|----------| +| `read_text_file` | Read file contents | Auto (all modes) | +| `read_dir` | List directory contents | Auto (all modes) | +| `write_text_file` | Create or overwrite a file | Approval (:do only) | +| `edit_text_file` | Search/replace edits in a file | Approval (:do only) | + +### Shell & System + +| Tool | Description | Approval | +|------|-------------|----------| +| `shell_exec` | Run a shell command in a visible terminal block | Approval | +| `cmd_history` | View recent command history | Auto | +| `get_scrollback` | Read terminal output from a block | Auto | +| `web_fetch` | Fetch a URL and extract text content | Auto in :ask, Approval in :do | + +### Workspace + +| Tool | Description | Approval | +|------|-------------|----------| +| `write_plan` | Write a plan file (used in :plan mode) | Auto in :plan | +| `create_block` | Create a new terminal or preview block | Approval | +| `focus_block` | Bring focus to a specific block | Auto | +| `spawn_task` | Delegate a sub-task to a child agent | Approval (:do only) | + +### Browser + +| Tool | Description | Approval | +|------|-------------|----------| +| `browser.navigate` | Navigate to a URL | Approval | +| `browser.read_text` | Read text content from the page | Approval | +| `browser.click` | Click an element on the page | Approval | +| `browser.screenshot` | Capture a screenshot of the page | Approval | + +--- + +## Session Commands + +All session commands are prefixed with `:`. + +| Command | Description | +|---------|-------------| +| `:new` | Clear the conversation and start a fresh session. | +| `:model <name>` | Switch to a different model. Resets the chat. | +| `:rewind` | Restore files changed by the last agent turn to their previous state. | +| `:worktree [name]` | Create a git worktree for sandboxed changes. | +| `:worktree exit` | Remove the active worktree and return to the main tree. | + +### Switching Models + +Use the **model chip** at the right end of the input bar (see "AI Provider Configuration" above). Open the picker, pick a model, the next message uses it. The choice persists on the current pane's outer block — different panes can run different models. + +The `:model` slash command from earlier crest versions is gone; the picker replaces it. + +--- + +## Diff Preview + +When the agent writes or edits a file, the approval card includes a diff preview: + +- Changed lines are shown with **3 lines of surrounding context** for orientation. +- **New files** display a green "New file" indicator. +- If a write produces no changes, a **"No changes"** label appears instead. + +--- + +## Plan-to-Do Handoff + +After `:plan` finishes and creates a plan file: + +1. An **Execute Plan** button appears below the agent response. +2. Clicking it switches to `:do` mode and sends "go" to begin execution. +3. The plan is already in the conversation history, so the agent has full context. + +--- + +## File Checkpointing and :rewind + +Every file write or edit the agent performs automatically creates a backup of the original content. + +``` +:rewind +``` + +- **Modified files** are restored to their pre-turn state. +- **Newly created files** are deleted. +- **Conversation history is not affected** — only files on disk change. +- Only tracks changes made by the Write and Edit tools. Changes made by shell commands (e.g., `sed`, `mv`) are not tracked. + +--- + +## Git Worktree Sandboxing + +Worktrees let you isolate agent changes in a separate git branch without affecting your working tree. + +``` +:worktree # creates worktree with random name (e.g., "calm-brook") +:worktree my-feature # creates worktree with a specific name +:worktree exit # removes the active worktree +``` + +- Creates `.crest/worktrees/<name>/` with branch `worktree-<name>`. +- All subsequent `:do` operations target the worktree directory. +- Persists across turns — the worktree stays active until you exit it. +- Add `.crest/worktrees/` to your `.gitignore`. + +--- + +## Sub-Agent Delegation + +The `spawn_task` tool (available in `:do` mode) delegates a scoped sub-task to a child agent. + +- The child agent runs with the same model and tools but in an **isolated conversation context**. +- Each sub-task is limited to **15 steps**. +- The child returns a completion summary when finished. +- Requires approval before spawning. + +--- + +## Background Shell Execution + +Run long-lived processes without blocking the agent: + +``` +shell_exec with background: true +``` + +- Returns immediately with the block ID. +- Useful for dev servers, file watchers, and long builds. +- Monitor output with `get_scrollback` using the returned block ID. + +--- + +## Dangerous Command Detection + +Certain destructive commands force an approval prompt regardless of context: + +- `rm -rf` +- `git push --force`, `git reset --hard` +- `curl | sh`, `wget | sh` +- `dd`, `mkfs`, `chmod 777` +- And other patterns covering destructive disk, git, and permission operations. + +There are 12 regex patterns in total. These commands will never auto-approve. + +--- + +## MCP Server Integration + +Extend the agent with external tools via the Model Context Protocol. + +### Configuration + +Add MCP servers in your `settings.json` under the `ai:mcpservers` key: + +```json +{ + "ai:mcpservers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@anthropic/mcp-filesystem"], + "type": "stdio" + } + } +} +``` + +### Supported Transports + +- **stdio** — spawns a local process +- **SSE** — Server-Sent Events over HTTP +- **HTTP** — standard HTTP transport + +### Using MCP Tools + +MCP tools appear with the naming convention `mcp__<server>__<tool>` and **always require approval**, regardless of mode. + +--- + +## Token Counter + +Cumulative token usage is displayed in the agent response area when the provider returns usage data. Models that report zero usage (some free-tier models) will not show a counter. diff --git a/docs/ai-config-architecture.md b/docs/ai-config-architecture.md new file mode 100644 index 000000000..a5e33760c --- /dev/null +++ b/docs/ai-config-architecture.md @@ -0,0 +1,746 @@ +# AI Config Architecture — Design Doc + +**Status:** **implemented (2026-05-20)** ¡ **Original draft:** 2026-05-19 ¡ **Owner:** TBD +**Replaces:** the dual `ai:*` global settings + `waveai@*` mode dict system +**References:** +- Warp source (`/Users/mac/Documents/open-source/warp`) — `crates/ai/src/api_keys.rs`, `crates/ai/src/agent/orchestration_config.rs`, `crates/ai/src/llm_id.rs` +- Crest, post-refactor — `frontend/app/store/ai-catalog.ts`, `pkg/aiusechat/aiconfig.go`, `pkg/aiusechat/userconfig.go`, `frontend/app/view/cmdblock/model-picker-popover.tsx` +- Companion arch note: [`agent-architecture.md`](./agent-architecture.md) §24 + +> **Scope decision (locked):** POC stage. **No backward compatibility.** Legacy `ai:*` settings and `waveai@*` mode dict are deleted, not deprecated. Users re-configure on upgrade. Migration script optional, not required. + +--- + +## 1. Why we're rewriting this + +Today crest has **two parallel AI config systems** that solve overlapping problems differently: + +| System | Where | Read by | Default | +|---|---|---|---| +| **Legacy `ai:*` global settings** | `settings.json` (`SettingsType.Ai*` fields) | `pkg/agent/http.go:buildAIOptsFromSettings()` | seeded with `gpt-5-mini` | +| **`waveai@*` mode dict** | `waveai.json` (`FullConfigType.WaveAIModes`) | `pkg/aiusechat/usechat.go:GetWaveAISettings()` | empty `{}` (recently seeded with 3 mock modes) | + +The agent HTTP handler picks one or the other based on whether the frontend sent an `aimode` in the request body. The picker UI only knows about the second. The legacy `ai:preset = "ai@global"` setting points at a third concept (`fullConfig.presets["ai@*"]`) that nothing actually reads. **Three overlapping layers, none authoritative.** + +### Concrete pain points + +1. **Zero discovery.** Default `waveai.json = {}`. Picker has nothing until user hand-writes JSON. The user has to know `ai:provider`, `ai:apitype`, `ai:endpoint`, capability lists, and which models exist for each provider — **none of which is the user's job to know**. + +2. **API token duplicated per mode.** Configuring four OpenAI modes (quick/balanced/deep/custom) means writing `ai:apitokensecretname: "OPENAI_API_KEY"` four times. There is no notion of "one token per provider, all that provider's modes share it." + +3. **Provider knowledge leaks into user config.** Things like "OpenAI's responses endpoint is `https://api.openai.com/v1/responses`" or "claude-3-7-opus supports reasoning" are objective provider facts but currently live in user-edited JSON. + +4. **Reasoning level abstracted at the wrong layer.** `ai:thinkinglevel` is a per-mode field, which means `gpt-5+medium` and `gpt-5+high` are two parallel modes instead of one model with two reasoning settings. + +5. **Naming baggage.** `waveai@quick/balanced/deep` evokes a Wave Cloud tier system crest doesn't have. + +6. **Token-resolution divergence.** Legacy `ai:*` and `waveai@*` each resolve API tokens through their own code path (`buildAIOptsFromSettings` vs `GetWaveAISettings`). They don't share or inherit; configuring one doesn't help the other. + +--- + +## 2. Target architecture: four layers + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ Layer 1 — CATALOG (static, in-repo) │ +│ What providers and models EXIST in the world. │ +│ Maintained by crest contributors via PR. │ +│ Holds: endpoint, apitype, capabilities, context window, │ +│ reasoning support, model display names. │ +│ ≈ warp's LLMInfo (but static, not proto-served). │ +├────────────────────────────────────────────────────────────────────┤ +│ Layer 2 — USER CONFIG (~/.config/crest/ai.json) │ +│ What the user wants. Three things only: │ +│ - which provider(s) they have keys for │ +│ - their default selection (provider + model + reasoning) │ +│ - optional named profiles (saved selections) │ +│ - optional custom_models (models not in catalog) and │ +│ custom_endpoints (e.g. OpenRouter, vLLM, Together AI) │ +├────────────────────────────────────────────────────────────────────┤ +│ Layer 3 — SELECTION (block.meta["agent:selection"]) │ +│ What's selected right now in this pane. │ +│ { provider, model, reasoning? } (an inline triple, │ +│ not a profile reference) │ +│ ≈ warp's per-conversation model_id. │ +├────────────────────────────────────────────────────────────────────┤ +│ Layer 4 — RESOLVER (pkg/aiusechat/resolver.go) │ +│ ResolveAIOpts(selection) → AIOptsType │ +│ 1. Catalog lookup → endpoint/apitype/capabilities │ +│ 2. User config → API token via secretstore │ +│ 3. Apply user overrides (custom endpoint etc.) │ +│ 4. Return existing AIOptsType, unchanged surface for backends │ +└────────────────────────────────────────────────────────────────────┘ +``` + +**Key property:** the only thing user-edited JSON needs to say is "I have an OpenAI key, I want gpt-5 as default." Everything else flows from catalog + secretstore. + +--- + +## 3. Layer 1: Catalog + +### Location + +**v1 — TS-only.** File: `frontend/app/store/ai-catalog.ts`. + +The Go-side resolver does **not** read this file. Instead, it gets the few facts it needs (endpoint, apitype, capabilities) **from the resolved selection itself** — the frontend, having read the catalog, sends a fully-resolved request body to the agent endpoint. See §6 for the request shape. + +Rationale: keeping catalog in TS avoids a round-trip through `task generate` for what is essentially a list of constants. Trade-off: the backend can't validate "did the user pick a real model?" — but the backend doesn't need to (LLM provider will reject if a name is bad, and the catalog is the only source). + +**If this becomes painful**, escalate to Go embed + `task generate`. Triggers for escalation: +- Backend wants to enumerate capabilities (e.g. "does this model support tools?") without trusting client +- We add server-side model availability checks +- Tools start reading model metadata + +### Schema (TS) + +```ts +// frontend/app/store/ai-catalog.ts + +export type ApiType = + | "openai-responses" + | "openai-chat" + | "google-gemini" + | "anthropic-messages"; + +export type Capability = "tools" | "images" | "pdfs" | "reasoning"; + +export type ReasoningLevel = "low" | "medium" | "high"; + +export interface ProviderEntry { + id: string; // "openai" | "anthropic" | "google" | "openrouter" | ... + displayName: string; // "OpenAI" + defaultEndpoint: string; // "https://api.openai.com/v1/responses" + defaultApiType: ApiType; + tokenSecretName: string; // default OS-keychain key name, e.g. "OPENAI_API_KEY" + icon: string; // UI icon name + models: ModelEntry[]; +} + +export interface ModelEntry { + id: string; // wire model id, e.g. "gpt-5" + displayName: string; // "GPT-5" + description?: string; // one-liner for the picker subtitle + capabilities: Capability[]; // ["tools", "images", "pdfs", "reasoning"] + contextWindow: number; // tokens + reasoningLevels?: ReasoningLevel[]; // present iff "reasoning" in capabilities + // Some providers ship the same model under a different apitype + // (e.g. older models behind openai-chat). When set, overrides the + // provider default. + apiTypeOverride?: ApiType; +} + +export const CATALOG: ProviderEntry[] = [/* §3.2 */]; +``` + +### v1 catalog content + +Concrete v1 list (will move with the AI market — kept honest by a single in-repo source-of-truth instead of dozens of user configs): + +```ts +export const CATALOG: ProviderEntry[] = [ + { + id: "openai", + displayName: "OpenAI", + defaultEndpoint: "https://api.openai.com/v1/responses", + defaultApiType: "openai-responses", + tokenSecretName: "OPENAI_API_KEY", + icon: "openai", + models: [ + { id: "gpt-5", displayName: "GPT-5", capabilities: ["tools","images","pdfs","reasoning"], contextWindow: 200000, reasoningLevels: ["low","medium","high"] }, + { id: "gpt-5-mini", displayName: "GPT-5 mini", capabilities: ["tools","images","pdfs"], contextWindow: 200000 }, + { id: "gpt-4o", displayName: "GPT-4o", capabilities: ["tools","images"], contextWindow: 128000, apiTypeOverride: "openai-chat" }, + { id: "o3-mini", displayName: "o3 mini", capabilities: ["tools","reasoning"], contextWindow: 200000, reasoningLevels: ["low","medium","high"] }, + ], + }, + { + id: "anthropic", + displayName: "Anthropic", + defaultEndpoint: "https://api.anthropic.com/v1/messages", + defaultApiType: "anthropic-messages", + tokenSecretName: "ANTHROPIC_API_KEY", + icon: "anthropic", + models: [ + { id: "claude-opus-4-7", displayName: "Claude Opus 4.7", capabilities: ["tools","images","pdfs","reasoning"], contextWindow: 1000000, reasoningLevels: ["low","medium","high"] }, + { id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6", capabilities: ["tools","images","pdfs","reasoning"], contextWindow: 200000, reasoningLevels: ["low","medium","high"] }, + { id: "claude-haiku-4-5-20251001", displayName: "Claude Haiku 4.5", capabilities: ["tools","images","pdfs"], contextWindow: 200000 }, + ], + }, + { + id: "google", + displayName: "Google Gemini", + defaultEndpoint: "https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent", + defaultApiType: "google-gemini", + tokenSecretName: "GOOGLE_AI_KEY", + icon: "google", + models: [ + { id: "gemini-2.0-pro", displayName: "Gemini 2.0 Pro", capabilities: ["tools","images","pdfs"], contextWindow: 2000000 }, + { id: "gemini-2.0-flash", displayName: "Gemini 2.0 Flash", capabilities: ["tools","images","pdfs"], contextWindow: 1000000 }, + ], + }, + { + id: "openrouter", + displayName: "OpenRouter", + defaultEndpoint: "https://openrouter.ai/api/v1/chat/completions", + defaultApiType: "openai-chat", + tokenSecretName: "OPENROUTER_API_KEY", + icon: "openrouter", + models: [ + // Curated subset; users can add more via user_config.custom_models. + { id: "anthropic/claude-opus-4-7", displayName: "Claude Opus 4.7 (via OpenRouter)", capabilities: ["tools","images"], contextWindow: 1000000 }, + { id: "openai/gpt-5", displayName: "GPT-5 (via OpenRouter)", capabilities: ["tools","images"], contextWindow: 200000 }, + ], + }, +]; +``` + +**v1 explicitly does not include:** Azure (per-deployment URL machinery is its own can of worms — re-add behind `apiTypeOverride: "openai-chat"` + `custom_endpoints` once the basics work), Groq, NanoGPT (both are openai-chat-compatible — users can add via `custom_endpoints`). + +--- + +## 4. Layer 2: User config + +### File + +`~/.config/crest/ai.json` — single file, replaces the AI portions of both `settings.json` and `waveai.json`. + +### Schema + +```jsonc +{ + // Required: which providers the user has keys for. Maps provider id + // (must match a CATALOG entry's id, or be the literal "custom") to + // its credential config. + "providers": { + "openai": { + // Either tokensecretname (preferred — fetched from OS + // keychain via secretstore) or token (literal — discouraged, + // for testing only). + "tokensecretname": "OPENAI_API_KEY" + }, + "anthropic": { + "tokensecretname": "ANTHROPIC_API_KEY" + } + }, + + // Required: the selection used when no per-pane override is set. + // Must point at a valid provider+model combination (catalog OR + // custom_models). + "default": { + "provider": "openai", + "model": "gpt-5", + "reasoning": "medium" // optional, only for models that support reasoning + }, + + // Optional: saved selections the user can pick from. Surface in the + // model picker as a "Profiles" section above the catalog list. + "profiles": { + "fast": { + "provider": "openai", + "model": "gpt-5-mini" + }, + "deepwork": { + "provider": "anthropic", + "model": "claude-opus-4-7", + "reasoning": "high" + } + }, + + // Optional: models not in the catalog that the user wants to use. + // Schema mirrors ModelEntry; provider must be a known provider id + // OR keyed to a custom_endpoints entry. + "custom_models": [ + { + "provider": "openrouter", + "id": "meta-llama/llama-3.3-70b-instruct", + "displayname": "Llama 3.3 70B", + "capabilities": ["tools"], + "contextwindow": 128000 + } + ], + + // Optional: provider overrides + entirely custom providers. + // Use this for vLLM, Together AI, Groq, local LM Studio etc. + "custom_endpoints": { + "vllm-local": { + "displayname": "Local vLLM", + "endpoint": "http://localhost:8000/v1/chat/completions", + "apitype": "openai-chat", + "tokensecretname": "VLLM_LOCAL_KEY", // can be empty for unauthed + "icon": "server", + "models": [ + { "id": "qwen-2.5-coder-32b", "displayname": "Qwen 2.5 Coder 32B", "capabilities": ["tools"], "contextwindow": 128000 } + ] + } + } +} +``` + +### Validation rules + +- `providers` keys must be either catalog provider ids or `custom_endpoints` keys. +- `default.provider` + `default.model` must resolve to either a catalog entry, a `custom_models` entry, or a `custom_endpoints` model. +- `default.reasoning` is only honored when the resolved model has `reasoning` capability. +- Empty config (`{}`) is **not valid** — crest refuses to start agent if no `default` is set. UI shows a "Configure AI" empty state pointing the user at the file. + +### Defaults + +There is **no embedded default `ai.json`**. On first run, the picker shows an empty state ("No providers configured — add one in `~/.config/crest/ai.json`"). Considered seeding a stub but rejected: any seeded default with a fake API key is misleading; clear empty state is more honest. + +--- + +## 5. Layer 3: Selection + +Each pane carries its currently-selected model in **block.meta on the outer block**. The key replaces the existing `waveai:mode`: + +```jsonc +// block.meta on the outer block +{ + "agent:selection": { + "provider": "openai", + "model": "gpt-5", + "reasoning": "high" // optional + } +} +``` + +Properties: +- **Per-pane** (matches warp's per-conversation grain — different panes can run different models). +- **Inline triple**, not a profile reference. If the user picks profile "fast" the picker resolves and writes `{provider, model, reasoning?}`. This makes selection self-describing — no second lookup needed at submit time, and the selection survives if the user later renames or deletes the profile. +- **Defaults to `user_config.default`** when unset (resolved at use time, not on pane create). +- **Persists across reload** (block.meta is durable). + +### Meta key migration + +| Old | New | Action | +|---|---|---| +| `block.meta["waveai:mode"]` | `block.meta["agent:selection"]` | Delete `WaveAIMode string` from `MetaTSType`, add `AgentSelection AgentSelectionMeta`. | +| `settings["ai:preset"]` | — | Delete. | +| `settings["ai:model"]` etc. | — | Delete (moves into `ai.json`). | +| `settings["waveai:defaultmode"]` | `ai.json default` | Delete; users write into ai.json. | + +--- + +## 6. Layer 4: Resolver + +### Location + +Resolver runs **in the frontend** before the agent request is dispatched. The HTTP request body to `/api/post-agent-message` carries a **fully-resolved** AI config block — the backend just hands it to `WaveAIPostMessageWrap` without further resolution. + +Why frontend: catalog lives there; resolution needs catalog + user config + selection; doing it server-side would require shipping catalog to Go too (§3 said no). + +### TS signature + +```ts +// frontend/app/store/ai-resolver.ts + +export interface ResolvedAIConfig { + provider: string; // resolved provider id + model: string; // resolved model id + endpoint: string; // final URL (with {model} substitution if the catalog used a template) + apiType: ApiType; + capabilities: Capability[]; + contextWindow: number; + reasoning?: ReasoningLevel; + tokenSecretName?: string; // resolver returns the *secret name*, backend dereferences via secretstore + tokenLiteral?: string; // only if user used `providers.<id>.token` instead of tokensecretname; passed through +} + +export function resolveAIConfig( + selection: AgentSelection, + userConfig: UserConfig, + catalog: ProviderEntry[], +): ResolvedAIConfig | ResolveError; +``` + +### Resolution algorithm + +``` +fn resolveAIConfig(selection, userConfig, catalog): + # 1. Find provider — catalog first, then custom_endpoints + provider = catalog.find(p => p.id === selection.provider) + ?? userConfig.custom_endpoints?.[selection.provider] + if !provider: + return err("Unknown provider: ${selection.provider}") + + # 2. Find model — provider.models, then custom_models + model = provider.models.find(m => m.id === selection.model) + ?? userConfig.custom_models?.find(m => m.provider === selection.provider && m.id === selection.model) + if !model: + return err("Model ${selection.model} not configured for provider ${selection.provider}") + + # 3. Read credentials from user config + credCfg = userConfig.providers[selection.provider] + if !credCfg: + return err("No API key configured for ${selection.provider}") + + # 4. Validate reasoning level (silently drop if unsupported) + reasoning = (selection.reasoning && model.capabilities.includes("reasoning")) + ? selection.reasoning + : undefined + + # 5. Substitute endpoint template if needed + endpoint = provider.defaultEndpoint.replace("{model}", model.id) + + # 6. Compose + return { + provider: selection.provider, + model: model.id, + endpoint, + apiType: model.apiTypeOverride ?? provider.defaultApiType, + capabilities: model.capabilities, + contextWindow: model.contextWindow, + reasoning, + tokenSecretName: credCfg.tokensecretname, + tokenLiteral: credCfg.token, + } +``` + +### Backend ingest + +The new agent request body shape: + +```jsonc +POST /api/post-agent-message +{ + "chatid": "...", + "tabid": "...", + "blockid": "...", + "mode": "do", + "msg": { ... }, + "context": { ... }, + "aiconfig": { + "provider": "openai", + "model": "gpt-5", + "endpoint": "https://api.openai.com/v1/responses", + "apitype": "openai-responses", + "capabilities": ["tools","images","pdfs","reasoning"], + "contextwindow": 200000, + "reasoning": "high", + "tokensecretname": "OPENAI_API_KEY" // backend reads from secretstore + } +} +``` + +Replaces the old `aimode` string field. The backend builds `AIOptsType` directly from this block (zero catalog access, zero `fullConfig.WaveAIModes` access). + +### Compatibility with existing AIOptsType + +`AIOptsType` (`pkg/aiusechat/uctypes/uctypes.go`) is **not changed**. The resolver output maps 1:1: + +| ResolvedAIConfig | AIOptsType field | +|---|---| +| `provider` | `Provider` | +| `model` | `Model` | +| `endpoint` | `Endpoint` | +| `apiType` | `APIType` | +| `capabilities` | `Capabilities` | +| `reasoning` | `ThinkingLevel` | +| `tokenSecretName`+secretstore lookup | `APIToken` | +| `tokenLiteral` (pass-through) | `APIToken` | + +`MaxTokens`, `Verbosity` etc. — for v1, use the same constants the current `applyProviderDefaults` uses (hard-coded sane defaults). If users need to override these, add per-provider overrides in `ai.json` later. + +--- + +## 7. Backend changes + +### Files modified + +| File | Change | +|---|---| +| `pkg/aiusechat/usechat.go` | Add `BuildAIOptsFromConfig(cfg AIConfigRequest) (*AIOptsType, error)` — pure ingest of the request block, no `fullConfig.WaveAIModes` lookup. | +| `pkg/aiusechat/usechat-mode.go` | **Delete entirely** (`resolveAIMode`, `getAIModeConfig`, `applyProviderDefaults`). The provider-defaults logic survives as much-simpler `applyDefaults(aiOpts)` that fills MaxTokens / capabilities-default. | +| `pkg/agent/http.go` | `PostAgentMessageRequest` gains `AIConfig AIConfigRequest` field (replaces `AIMode string`). Handler calls `BuildAIOptsFromConfig` unconditionally — delete `buildAIOptsFromSettings` (and its sole call site). | +| `pkg/wconfig/settingsconfig.go` | Delete `SettingsType.Ai*` fields. Delete `AIModeConfigType`, `AIModeConfigUpdate`. Delete `FullConfigType.WaveAIModes`. | +| `pkg/wconfig/defaultconfig/settings.json` | Delete `ai:preset`, `ai:model`, `ai:maxtokens`, `ai:timeoutms`, `waveai:defaultmode`, `waveai:showcloudmodes`. | +| `pkg/wconfig/defaultconfig/waveai.json` | **Delete the file.** | +| `pkg/aiusechat/uctypes/uctypes.go` | Delete `AIModeQuick / AIModeBalanced / AIModeDeep` constants. | +| `pkg/waveobj/wtypemeta.go` | Replace `WaveAIMode string` with `AgentSelection *AgentSelectionMeta`. Run `task generate`. | +| `pkg/wshrpc/wshrpctypes.go` | Delete `GetWaveAIModeConfigCommand` RPC and `AIModeConfigUpdate` type. Run `task generate`. | +| `pkg/wshrpc/wshserver/wshserver.go` | Delete `GetWaveAIModeConfigCommand` impl. | + +### New files + +| File | Purpose | +|---|---| +| `pkg/aiusechat/aiconfig.go` | `AIConfigRequest` struct (matches frontend `ResolvedAIConfig`); `BuildAIOptsFromConfig`. | + +### Backward-compat exceptions + +None. POC stage. + +--- + +## 8. Frontend changes + +### Files modified + +| File | Change | +|---|---| +| `frontend/app/view/cmdblock/model-picker-popover.tsx` | Read from catalog + user_config.profiles + user_config.custom_models. Render grouped sections (Profiles / Catalog by provider / Custom). Pick writes `block.meta["agent:selection"]`. | +| `frontend/app/view/cmdblock/cmdblock-input.tsx` | `ModelEntry` shape changes (gets `provider` field; sub-list semantics for reasoning). | +| `frontend/app/term/render/terminal-view.tsx` | Replace the `fullConfig.waveai` derivation with `(catalog, userConfig)` derivation. Use `agent:selection` meta key. Pass resolved `AIConfigRequest` to AgentChatHost. | +| `frontend/app/term/render/agent-chat-host.tsx` | `body.aimode` → `body.aiconfig` (the resolved block). | +| `frontend/app/store/aitypes.ts` | Drop `AIModeConfigType` re-exports. Add `AgentSelection`, `UserConfig`, `ResolvedAIConfig`, `AIConfigRequest`. | +| `frontend/types/custom.d.ts` | `hasCustomAIPresetsAtom` — review whether anything still needs it; likely delete. | + +### New files + +| File | Purpose | +|---|---| +| `frontend/app/store/ai-catalog.ts` | The static `CATALOG` const + types. | +| `frontend/app/store/ai-resolver.ts` | `resolveAIConfig` function + tests. | +| `frontend/app/store/ai-user-config.ts` | Jotai atom for `ai.json`, loader/saver via `getApi().readFile` / a new `WriteAIConfigCommand` RPC. | + +### Deleted files + +`frontend/app/view/waveconfig/waveaivisual.tsx` is the old AI mode config UI. Either rebuild it as an "Edit ai.json" view or remove and let users edit the JSON directly. **v1: remove.** Power users edit the file. + +--- + +## 9. Selection store / UI behavior + +``` +┌──────────────────────────────────────────────────────────┐ +│ Picker layout (warp-style sections) │ +├──────────────────────────────────────────────────────────┤ +│ PROFILES │ +│ ★ fast gpt-5-mini │ +│ ★ deepwork claude-opus-4-7 ¡ high │ +├──────────────────────────────────────────────────────────┤ +│ OPENAI │ +│ ◯ gpt-5 (200k ¡ tools ¡ reasoning) │ +│ ◯ gpt-5 mini (200k ¡ tools) │ +│ ◯ gpt-4o (128k ¡ tools) │ +│ ◯ o3 mini (200k ¡ reasoning) │ +├──────────────────────────────────────────────────────────┤ +│ ANTHROPIC │ +│ ◯ Claude Opus 4.7 (1M ¡ reasoning) │ +│ ✓ Claude Sonnet 4.6 (200k ¡ reasoning) ← selected │ +│ ◯ Claude Haiku 4.5 (200k) │ +├──────────────────────────────────────────────────────────┤ +│ GOOGLE GEMINI │ +│ ◯ Gemini 2.0 Pro (2M) │ +│ ◯ Gemini 2.0 Flash (1M) │ +├──────────────────────────────────────────────────────────┤ +│ CUSTOM ENDPOINTS │ +│ ◯ Local vLLM / qwen-2.5-coder-32b │ +├──────────────────────────────────────────────────────────┤ +│ 🔍 Search models │ +├──────────────────────────────────────────────────────────┤ +│ ↑↓ navigate ¡ â†ĩ select ¡ esc dismiss │ +└──────────────────────────────────────────────────────────┘ +``` + +Sections shown only when non-empty. A provider whose key isn't in `user_config.providers` renders **dimmed** with a "Add API key" affordance. + +### Reasoning sub-select + +For models with `reasoningLevels`, hovering shows a 3-button mini-row inline below the row (`low / med / high`). Click sets the level + commits selection. Default: provider's default level (we pick `medium`). + +This replaces warp's hover-sidecar UX with an inline row — simpler and good enough for v1. Sidecar is future polish. + +--- + +## 10. Deletion kill-list + +For the implementer: a complete enumeration so nothing rots. + +### Go files (full delete) +- `pkg/aiusechat/usechat-mode.go` (whole file) — after porting `applyDefaults` into `aiconfig.go` + +### Go fields/types/constants +- `SettingsType.AiApiType / AiApiToken / AiApiTokenSecretName / AiBaseURL / AiMaxTokens / AiTimeoutMs / AiModel / AiPreset` (and any sibling `AiOrgID` etc.) +- `AIModeConfigType` (whole type) +- `AIModeConfigUpdate` (whole type) +- `FullConfigType.WaveAIModes` +- `MetaTSType.WaveAIMode` +- `uctypes.AIModeQuick / AIModeBalanced / AIModeDeep / AIModeBuilderDefault / AIModeBuilderDeep` +- `PostAgentMessageRequest.AIMode` (replaced by `AIConfig`) +- `PostMessageRequest.AIMode` (chat panel — same treatment) +- `wshserver.GetWaveAIModeConfigCommand` + +### JSON keys (default config files) +- `settings.json`: `ai:preset`, `ai:model`, `ai:maxtokens`, `ai:timeoutms`, `waveai:defaultmode`, `waveai:showcloudmodes` +- `waveai.json`: file deleted + +### TS/JSX +- `frontend/app/view/waveconfig/waveaivisual.tsx` (whole file) +- `frontend/app/store/global-atoms.ts:hasCustomAIPresetsAtom` (or rewrite if still wanted) +- `frontend/types/gotypes.d.ts` regenerated → `AIModeConfigType`, `AIModeConfigUpdate` disappear automatically +- `frontend/app/store/wshclientapi.ts:GetWaveAIModeConfigCommand` regenerated → disappears + +### Block meta migration +- `block.meta["waveai:mode"]` → frontend ignores on read (old field gets silently dropped on first selection write) +- POC: no migration; users re-pick. + +--- + +## 11. Phase plan + +Six phases. Each ends with a green-test checkpoint. Cumulative — phases don't ship independently, the rewrite goes in one branch. + +### Phase A — Catalog + types (~1d) +- Create `ai-catalog.ts` with v1 content. +- Add types: `AgentSelection`, `UserConfig`, `ResolvedAIConfig`, `AIConfigRequest`. +- Add Go `AIConfigRequest` struct in `pkg/aiusechat/aiconfig.go`. +- Add `MetaTSType.AgentSelection` + `task generate`. + +**Acceptance**: TS compiles; new types appear in `gotypes.d.ts`; existing tests still pass. + +### Phase B — Resolver + secret pipe (~1d) +- Implement `resolveAIConfig` in `ai-resolver.ts` with full unit tests (vitest). +- Implement `BuildAIOptsFromConfig` in `pkg/aiusechat/aiconfig.go` with Go unit tests. +- Wire secretstore lookup in `BuildAIOptsFromConfig`. + +**Acceptance**: `vitest run frontend/app/store/ai-resolver.test.ts` and `go test ./pkg/aiusechat/...` both green. Round-trip test: `selection → ResolvedAIConfig → AIConfigRequest → BuildAIOptsFromConfig → AIOptsType` produces the right endpoint+apitype for each catalog provider. + +### Phase C — User config loader (~0.5d) +- Add `ai-user-config.ts` jotai atom + reader. +- Read path: backend `wshrpc` command `GetAIUserConfig` returns parsed `~/.config/crest/ai.json` (or error). Write path: `WriteAIUserConfig` accepts a JSON blob. +- Empty-config UX: a banner in the picker pointing at the file path. + +**Acceptance**: Picker mounts without crash when ai.json missing; banner visible. + +### Phase D — Picker rewrite (~1d) +- Rewrite `model-picker-popover.tsx` for the sectioned layout. +- Rewrite `terminal-view.tsx` deriveModels logic. +- Reasoning inline sub-row. + +**Acceptance**: Manual smoke — picker shows catalog correctly; selecting writes `block.meta["agent:selection"]`; reload preserves selection. + +### Phase E — Backend cutover + delete legacy (~1d) +- Switch `PostAgentMessageHandler` to consume `req.AIConfig` exclusively. +- Switch `WaveAIPostMessageHandler` (chat panel) similarly. +- Delete every item in §10 kill-list. +- `task generate`. +- Search the codebase for any straggler references — expected hits: 0 after a clean delete. + +**Acceptance**: `go build ./...` green; `npx tsc --noEmit` clean of `AIMode*` / `WaveAIMode*` refs; `go test ./...` green. + +### Phase F — End-to-end smoke + docs (~0.5d) +- Manual: configure OpenAI key, send agent message → reply arrives. +- Manual: switch providers via picker → next message uses new provider. +- Update `docs/agent-architecture.md` §13 (or new §) pointing here. +- Update `docs/agent-user-guide.md` with the new `ai.json` shape + example. + +**Acceptance**: agent reply arrives for at least one BYOK provider in dev environment. + +### Total: ~5 days single-person, ~3-4 days if parallelizing FE/BE. + +--- + +## 12. Decisions log + +Locked decisions, written down so future-us doesn't relitigate them: + +| Decision | Reason | +|---|---| +| Catalog is TS-only in v1, not Go-embedded | Avoid `task generate` cycle for what is essentially a constant list. Backend doesn't need catalog access — it ingests resolved configs. Easy to escalate to Go embed later if needed. | +| Selection is an inline triple `{provider,model,reasoning?}`, not a profile reference | Self-describing; survives profile deletion; one-shot resolution. | +| No embedded default `ai.json` | A seeded default with fake API key is misleading. Honest empty state with clear path to fix is better. | +| Selection lives per-pane (block.meta), not global | Matches warp; lets different panes run different models. Trade-off: there's no global default-mode-per-tab fallback — defaults flow from `ai.json.default`. | +| No `mode` (ask/plan/do/bench) folded into selection | These are permission-posture axes, orthogonal to model choice. `mode` stays in its current spot. | +| `MaxTokens` etc. not user-configurable in v1 | Hard-coded sane defaults. Avoids growing the v1 schema; revisit if anyone hits a wall. | +| Azure / Groq / NanoGPT not in v1 catalog | Each has deployment-URL or rate-limit specifics. Users can add via `custom_endpoints` until we standardize. | +| Reasoning select is inline mini-row, not hover sidecar | Simpler than warp's sidecar; faithfulness to warp not a goal here. | +| Delete `waveaivisual.tsx` (old AI config UI) | v1: edit JSON. Rebuild as a real settings pane is post-v1. | +| No backward compat / migration script | POC stage; users re-pick. Migration code is dead weight + a bug surface. | + +--- + +## 13. Open questions + +Things the implementer should resolve before / during work: + +1. **Where to surface "API key missing"?** Picker shows dim + "Add API key" — but clicking that should open a flow. Options: open `ai.json` in $EDITOR / inline form / docs link. **Default: open the file path with `getApi().openExternal('file://...')`.** +2. **Should `default.reasoning` be a separate axis at config time, or always picked at use time?** Current proposal: configurable in `ai.json` (so the user's preferred default reasoning level survives picker resets). Confirm via UX try. +3. **Token literal vs secret name precedence.** If both `token` and `tokensecretname` are set on a provider, which wins? Proposal: `token` (literal) wins. Log a warning. +4. **Catalog `apiTypeOverride` semantics with custom endpoints.** When a user adds a custom_endpoints entry, do its models inherit `apitype` from the endpoint or can per-model overrides apply? Proposal: endpoint-level only in v1, no per-model override on custom. +5. **Multi-key per provider.** Some users have separate OpenAI keys for personal vs work projects. Not addressed by v1. Punt. +6. **What about chat-panel (`WaveAIPostMessage`)?** Same treatment as agent: consume `AIConfigRequest` in body. Confirm during Phase E. + +--- + +## 14. What is explicitly out of scope + +To prevent scope creep during implementation, listed: + +- ❌ A real GUI for editing `ai.json` (post-v1) +- ❌ Multi-key per provider (post-v1) +- ❌ Per-provider `MaxTokens` / `Verbosity` overrides (post-v1) +- ❌ Server-side model availability validation (we trust catalog + user config) +- ❌ Model auto-routing ("Auto" mode that picks model per message) — distinct feature, separate design +- ❌ Pricing / quota display in picker (no catalog data on pricing yet) +- ❌ Warp-style hover sidecar for reasoning levels (use inline row) +- ❌ Azure / Groq / NanoGPT in v1 catalog (custom_endpoints handles them) +- ❌ Backward compatibility with existing block.meta["waveai:mode"] or settings.json `ai:*` + +--- + +## 15. Ready-to-implement checklist + +Before starting Phase A: +- [ ] User approves architecture (this doc) +- [ ] User approves v1 catalog content (§3.2 — specifically the model lists) +- [ ] Resolve open questions §13.1, §13.2 (default UX) before Phase D +- [ ] Decide §13.6 (chat panel) before Phase E + +Once green, branch from `main` and Phase A. + +--- + +## 16. What shipped (post-implementation appendix, 2026-05-20) + +Phases A–F ran end-to-end across the design timeline. Notes on **what diverged from the original plan** in this section; everything not mentioned shipped as drafted. + +### Deviations + +- **Reasoning sub-row decision (§13.2 carried forward).** Confirmed inline three-button mini-row under the active model row instead of warp's hover sidecar. Implementation in `model-picker-popover.tsx`'s `ReasoningSubRow`. UX trade: zero hover surface, always-visible state — better discoverability, slightly heavier vertical footprint when a reasoning-capable model is active. + +- **`AIOptsType.AIMode` field removed too (§10 kill-list addition).** Originally planned to delete only the AIMode wire path; during Phase E it became clear the `AIMode` *label field* on `AIOptsType` (and its propagation through `WaveChatMetrics.AIMode` → `telemetrydata.WaveAIMode`) had no remaining producer. Deleted alongside. + +- **`ObjRTInfo.WaveAIMode` deleted.** Same reason — the runtime-info copy of the mode key had no consumer once `getWaveAISettings` was removed. + +- **Phase E touched more files than projected.** Original §10 kill-list named ~22 items; the actual cutover hit ~30 once we walked the import graph (telemetry, ObjRTInfo, frontend mock config, monaco schema endpoint, `validateWaveAiJson` validator). The pattern was the same — kill the producer, fix the dangling consumers — but the consumer list ran longer than the doc anticipated. + +- **One Go field intentionally kept stale: `CountCustomAIModes`.** Replaced its body with `return 0` rather than deleting the method because the cmd/server telemetry path still calls it; preserving the method signature keeps the telemetry struct shape stable for downstream dashboards. Real cleanup is a follow-up that touches the telemetry consumer too. + +- **No migration script written (§13.5 punt confirmed).** POC stage; users re-pick. The picker's empty-state banner explains where to put `ai.json`. + +- **AgentChatHost has no fallback when `aiConfig` is null.** Plan said "the picker handles empty / error states internally" — that's true for the popover UI, but `AgentChatHost.submit` had to add an explicit guard (logs a warning to console + drops the message) because the backend now hard-requires `aiconfig`. Phase E commit added the guard; without it, a fresh install with no `ai.json` would 400 on first agent message. + +- **Tooling note: `tsc` and `vitest` are dysfunctional in this dev environment** (some `node_modules` files read as 0 bytes despite `ls` showing them populated — `typescript/lib/_tsc.js`, `rollup/package.json`, `picomatch`). Resolver tests run via `tsx` directly; the canonical `vitest.config.ts` will pass once `node_modules` is reinstalled. A `vitest.slim.config.ts` exists at the repo root as a temporary bypass. + +### Test coverage shipped + +- `pkg/aiusechat/aiconfig_test.go` — 13 tests covering token literal, secretstore lookup, whitespace trim, secret-missing, secret-empty, secretstore-error, full field mapping, capability roundtrip, required-field rejection (4 subtests), nil capabilities, no-reasoning. +- `pkg/aiusechat/aiconfig_roundtrip_test.go` — 5 cross-language round-trip tests (OpenAI w/ reasoning, Google Gemini w/ template, OpenRouter chat API, local vLLM unauthed, literal-token testing path). +- `pkg/aiusechat/userconfig_test.go` — 10 parse + validation tests (minimal, full, empty rejection, missing providers, empty providers, missing default fields, unknown field rejection, malformed JSON, missing-file sentinel). +- `frontend/app/store/ai-resolver-smoke.ts` — 26 assertions covering catalog hit, default fallback, apitypeoverride, `{model}` substitution, reasoning gating, credential precedence, empty tokensecretname, custom_endpoints, custom_models, all 5 error codes. + +### Open follow-ups (post-v1) + +- `crest secret set OPENAI_API_KEY sk-...` wsh command (referenced in user guide; not yet implemented). Today users set keychain entries via system tools. +- "Open `ai.json` in $EDITOR" affordance on the picker's empty-state banner — currently surfaces the path in a notification toast. +- Azure / Groq / NanoGPT catalog entries (custom_endpoints works today; first-class slots once we standardize their per-deployment URL machinery). +- GUI for editing `ai.json` (post-v1 — `waveaivisual.tsx` was deleted intentionally; rebuild belongs in a separate design). +- File-citation jump in the agent's tool-use cards (P0.7 left as clipboard fallback — proper "scroll to block + line" needs a filename→block index that doesn't exist yet). + +### File index + +For the implementer hunting for any of this later: + +- Design doc: this file (`docs/ai-config-architecture.md`) +- Architecture cross-ref: `docs/agent-architecture.md` §24 +- User guide: `docs/agent-user-guide.md` § "AI Provider Configuration" +- Catalog: `frontend/app/store/ai-catalog.ts` +- FE types: `frontend/app/store/ai-types.ts` +- FE resolver: `frontend/app/store/ai-resolver.ts` (+ `ai-resolver-smoke.ts`, `ai-resolver.test.ts`) +- FE user-config loader: `frontend/app/store/ai-user-config.ts` +- FE picker: `frontend/app/view/cmdblock/model-picker-popover.tsx` +- Go AIUserConfig types: `pkg/aiusechat/uctypes/userconfig.go` +- Go user-config IO: `pkg/aiusechat/userconfig.go` (+ `userconfig_test.go`) +- Go AIConfigRequest + resolver: `pkg/aiusechat/aiconfig.go` (+ `aiconfig_test.go`, `aiconfig_roundtrip_test.go`) +- RPC: `pkg/wshrpc/wshrpctypes.go` (`GetAIUserConfigCommand` / `WriteAIUserConfigCommand`) +- Meta: `pkg/waveobj/wtypemeta.go` (`MetaTSType.AgentSelection`, `AgentSelectionMeta`) diff --git a/docs/ai-sdk-provider-migration-eval.md b/docs/ai-sdk-provider-migration-eval.md new file mode 100644 index 000000000..295ba3af1 --- /dev/null +++ b/docs/ai-sdk-provider-migration-eval.md @@ -0,0 +1,254 @@ +# Migrating the AI Agent Layer — Evaluation + +**Status:** evaluation only ¡ **Owner:** TBD ¡ **Decision deadline:** none set +**Companion:** [`ai-config-architecture.md`](./ai-config-architecture.md) + +This doc evaluates moving crest's LLM client layer off the four hand-rolled Go backends in `pkg/aiusechat/`. It does **not** propose a schedule; the goal is to hand whoever picks this up a clear picture of scope, risk, and trigger conditions. + +> **Recommended path:** Option D (move agent loop into Electron main, use ai-sdk providers). See §3 / §7 for the reasoning. Options A–C in §6 are alternative paths kept around because future shifts in priorities (e.g. dropping Electron, going pure-web) could make them relevant — they are not the recommendation. + +--- + +## 1. Current architecture (May 2026) + +The AI request path runs: + +``` +FE useChat (@ai-sdk/react) ← Electron renderer (JS) + → POST /api/post-agent-message ← HTTP, app-internal + → pkg/agent/http.go ← Go wavesrv (independent daemon) + → pkg/agent/run.go ← agent loop: tools, checkpoints, posture + → pkg/aiusechat/<backend>/ ← one of 4 hand-rolled backends + → upstream LLM HTTP API ← OpenAI / Anthropic / Google / OpenRouter + ← SSE chunks + ← UIMessagePart stream ← ai-sdk wire shape + ← SSE chunks + ← ai-sdk UIMessage updates +``` + +`pkg/aiusechat/` totals ~3000 LOC across the four backends (`openaichat/`, `openairesponses/`, `anthropic/`, `gemini/`). Each implementation re-derives: + +- Provider-specific request body shapes (different JSON per vendor) +- SSE → `UIMessagePart` translation (so the FE sees a uniform shape) +- Crest tool-call protocol ↔ provider tool-call JSON (different per vendor) +- Provider-specific errors (throttling, content policy, context-window exceeded) + +The FE uses `@ai-sdk/react` (`useChat`) and emits the `UIMessage` wire shape from Go — but **does not** consume any ai-sdk provider package (`@ai-sdk/openai`, `@ai-sdk/anthropic`, `@ai-sdk/google`). The Go backends re-implement what those packages already do. + +## 2. The framing trap + +A naive read of the problem is "do we want ai-sdk providers on the FE or do we keep the Go backend?". That framing produces three bad options (covered in §6 as Options A/B/C), each with a serious tradeoff: + +- **A (FE provider plugins direct to upstream)** — moves API keys into the Electron renderer (security regression). +- **B (replace `pkg/aiusechat/` with Go SDKs)** — doesn't actually use ai-sdk; only collapses the per-vendor code shape; still leaves crest with multi-vendor maintenance. +- **C (BE proxy + FE provider plugins)** — still requires migrating the agent loop to the renderer (largest blast radius, displaced tool execution). + +These look like three flavors of the same constraint. They aren't — they're three flavors of an **assumed** constraint: that the agent loop lives in Go wavesrv and must stay there. That assumption is what makes the choice feel stuck. + +The right question is: **why does our agent loop live in a separate daemon at all?** + +## 3. Industry tells: nobody else does this + +Every comparable product runs `{ agent loop, LLM client, tool execution }` **in the same process**. The only thing that varies is *which* process. + +| Product | Surface | Where the agent lives | LLM client | Tool exec | +|---|---|---|---|---| +| **Claude Code** (CLI) | terminal | Node CLI process | `@anthropic-ai/sdk` | Same process | +| **Aider** (CLI) | terminal | Python process | LiteLLM | Same process | +| **Cursor** (Electron) | IDE | VS Code extension host (Node) | Custom client | Same process | +| **Continue.dev** | VS Code ext | Extension host (Node) | ai-sdk + custom | Same process | +| **Cline / Roo Code** | VS Code ext | Extension host (Node) | ai-sdk | Same process | +| **Claude Desktop** | Electron app | Electron main (Node) | `@anthropic-ai/sdk` | Same process | +| **ChatGPT Desktop** | Tauri app | Tauri backend (Rust) | Custom | Same process | +| **OpenWebUI** | web app | Python server | LiteLLM | Same process | +| **OpenHands / OpenDevin** | web app | Python server | LiteLLM | Same process | +| **crest** (today) | Electron app | **Separate Go wavesrv daemon** | 4 hand-rolled Go backends | wavesrv | + +Crest is the only entry whose agent loop runs in a process that's **not** the obvious "main app process". Every other Electron-based product in the table puts the agent in the Electron main process — not the renderer, not a sidecar daemon. + +This isn't an accident of small sample size. It's a recurring choice because **the agent loop touches three things at once** — model client, conversation persistence, tool execution against the host machine — and splitting any of them across a process boundary buys complexity (IPC, error propagation, lifecycle coordination) for no offsetting benefit. + +### Why crest looks different + +Git archaeology (see ai-config-architecture refactor history): `pkg/aiusechat/` was added in **2025-10** during the Wave Terminal era, when Wave was "a terminal with an AI sidebar". Wave's pre-existing architecture was `Electron renderer ↔ wavesrv` because PTY management requires a long-lived backend. AI got bolted into wavesrv because **the infrastructure was already there** (secretstore, websocket transport, chatstore-able SQLite). + +Crest forked from Wave in **2026-04** and built `pkg/agent/` (native coding agent, 100% crest-original) on top of the same `pkg/aiusechat/`. The agent's location in wavesrv is **not** a design decision; it's the path of least resistance for the original Wave authors. Nobody picked it for crest's needs. + +## 4. The actual best practice + +For an Electron AI product, the standard layout is: + +``` +┌────────┐ IPC ┌────────────────────┐ +│Renderer│←─────────→│Electron Main (Node)│ +│(React) │ │ - agent loop │ +│ │ │ - ai-sdk providers │ +│useChat │←─stream───│ - tool dispatch │ +└────────┘ │ - keychain access │ + │ - conversation │ + │ persistence │ + └────────────────────┘ + ↓ + upstream LLM API +``` + +The renderer stays a pure UI surface. The main process owns everything the agent does. API keys never leave the main process. Tool execution runs in the same process as orchestration, so there's no inter-process round-trip per tool call. + +ai-sdk's [official docs](https://sdk.vercel.ai/docs) describe the "server-side route handler that calls `streamText`" pattern. In a web app the "server side" is a Next.js API route. In an Electron app the equivalent is the main process — same role, different host. The shape is identical: UI hook ↔ in-process route ↔ ai-sdk provider ↔ upstream. + +## 5. Option D — Move the agent into Electron main (recommended) + +Proposed target architecture: + +``` +┌────────┐ IPC ┌──────────────────┐ ┌──────────────┐ +│Renderer│←─────→│Electron Main │←wshrpc→ │Go wavesrv │ +│(React) │ │ (Node) │ │ │ +│ │ │ ─ agent loop │ │ Stays as: │ +│useChat │←──────│ ─ ai-sdk client │ │ ─ PTY mgr │ +│ │ │ ─ tool dispatch │ │ ─ block IO │ +└────────┘ │ ─ chatstore │ │ ─ blockmeta │ + │ ─ checkpointstore │ │ ─ wstore DB │ + │ ─ secretstore │ │ ─ tab events │ + └──────────────────┘ └──────────────┘ + ↓ + upstream LLM API +``` + +What moves: + +- `pkg/agent/run.go` → Electron main (`emain/agent/` or similar) — agent loop, posture enforcement, plan progression, tool gating +- `pkg/agent/tools/` → Electron main — file IO and shell exec become Node calls; tools that need wavesrv (block snapshots, PTY input) keep using wshrpc as a remote call +- `pkg/aiusechat/*` → **deleted** — replaced by `@ai-sdk/openai`, `@ai-sdk/anthropic`, `@ai-sdk/google`, `@openrouter/ai-sdk-provider` +- `pkg/agent/chatstore.go` → main-side SQLite via `better-sqlite3` +- `pkg/agent/checkpoint.go` → main-side filesystem +- `pkg/secretstore/` → Electron's `safeStorage` API (system-keychain-backed) **or** stays in wavesrv and main calls via wshrpc — open question, see §8 + +What stays in wavesrv: + +- PTY management (the original reason wavesrv exists) +- Block IO (terminal output capture, scroll buffer, ANSI parsing) +- `wstore` DB (block / tab / window persistence, meta key reads) +- WPS event bus (only meaningful for terminal-side events anyway) + +What the renderer sees: + +- ai-sdk's `useChat` still works exactly as today, but its transport posts to an Electron IPC channel instead of HTTP to wavesrv. The wire format (`UIMessage` parts) stays the same — that's an ai-sdk standard. + +### Why Option D dissolves the A/B/C tradeoffs + +| Concern | A | B | C | **D** | +|---|---|---|---|---| +| Uses ai-sdk providers | ✅ | ❌ | ✅ | ✅ | +| API keys stay out of renderer | ❌ | ✅ | ✅ | ✅ | +| Agent loop stays out of renderer | ❌ | ✅ | ❌ | ✅ | +| Per-tool inter-process round-trip | yes (FE→BE for each tool) | no | yes | no | +| Deletes `pkg/aiusechat/*` (~3000 LOC) | ✅ | âš ī¸ replaced, not deleted | ✅ | ✅ | +| wavesrv keeps its single responsibility | ❌ | ❌ | ❌ | ✅ | + +Option D is the only path that scores ✅ on every dimension. It is also the path that aligns with what every other Electron AI product does. + +### Effort + +Calendar estimate: **2–3 weeks** for a working migration, ~1 additional week for production-quality (regression tests, parity bench across providers). + +LOC delta: + +- `pkg/aiusechat/*` removed (~3000 LOC Go) +- `pkg/agent/run.go` + tools removed from Go (~1500 LOC Go) +- Electron main agent layer added (~2500 LOC TS) — smaller because ai-sdk handles streaming/tool-protocol/error-classification for free +- Net: **~−2000 LOC** for crest, plus the maintenance burden of the 4 vendor backends moves to Vercel + +Risk: medium. The blast radius is large (deletes a whole subsystem) but well-bounded — the FE wire shape doesn't change, and wavesrv's terminal responsibilities are untouched. + +### Open questions for the implementer + +1. **Where does `secretstore` end up?** Two choices: + - Move secrets to Electron `safeStorage` (system-keychain-backed). Cleaner, no IPC for token reads. Requires migrating existing user keychain entries. + - Keep secrets in wavesrv; main calls `GetSecret` via wshrpc on each LLM request. One extra round-trip per request (~ms), no migration. + - **Default proposal:** keep in wavesrv for v1 (one round-trip is cheap; migration is annoying), revisit if it shows up in latency profiling. +2. **chatstore migration.** wavesrv's chatstore is JSON files in `~/.local/share/crest{-dev}/chatstore/`. Main-side equivalent is trivial (Node `fs`), but existing user conversations need either a copy on first run or a wshrpc-shim that lets main read wavesrv's directory. +3. **Tool execution boundary.** Some tools naturally belong in wavesrv (block snapshots, shell input injection); others naturally belong in main (filesystem ops, git, web fetch). Need an explicit list — most tools probably move to main because wavesrv has nothing they need. +4. **wavesrv's existing `/api/post-agent-message` HTTP handler.** Delete it, or keep as a back door for external callers (CLI scripts, harness)? Bench harness (`task bench`) currently calls it directly — needs to either migrate to call main process via IPC or stay on the wavesrv path with a clean separation. + +## 6. Alternative paths (kept for completeness, not recommended) + +These were the only options visible when the dilemma was framed as "FE provider plugins vs Go backend". Each carries the cost Option D avoids; document here so future readers can see why they were considered and ruled out. + +### Option A — Use ai-sdk providers in the renderer; tool calls round-trip to wavesrv + +``` +Renderer → streamText({ model: anthropic(...), tools }) — direct to upstream + ↑ ↓ + └── wshrpc ── wavesrv tool executor ── wshrpc ──────────┘ +``` + +- API keys **in the renderer process**, transmitted to upstream APIs from there. Security regression vs current setup. Mitigations all reinvent a backend signing layer. +- Per-tool round-trip: each tool call is renderer → wavesrv → renderer. +- Loses the central place to put rate limiting, request logging, audit. + +### Option B — Replace `pkg/aiusechat/*` with Go SDKs (`openai-go`, `anthropic-sdk-go`, `google.golang.org/genai`) + +- Doesn't actually adopt ai-sdk; uses different per-vendor official Go SDKs. +- Preserves the wavesrv-owned agent loop, so no IPC reshape. +- Saves maintenance on the SSE/tool-protocol code but doesn't address the structural source-of-truth issue (still 3 vendor SDKs to track). +- Smaller calendar (~1 week) but doesn't deliver the ai-sdk ecosystem benefits. + +### Option C — Hybrid: wavesrv proxies, renderer uses ai-sdk providers with `baseURL` pointing at wavesrv + +- API keys stay server-side (good). +- Each ai-sdk provider has its own assumption about base URL shape; some proxy cleanly, others don't (Gemini's `{model}` URL template, OpenAI Responses' nested path). +- Tool execution still needs round-tripping; **agent loop still has to move to the renderer**, which is the largest cost piece. +- All the work of D, without the win of putting orchestration in main. + +## 7. Recommendation + +Pick **Option D** when the work can be scheduled. + +The case for D is structural, not aesthetic: + +- It corrects the historical accident (agent in wavesrv) that turned a clean problem into a stuck tradeoff. +- It aligns crest with the de facto pattern every comparable Electron product uses. +- It deletes more code than it adds, with maintenance work shifted onto upstream (Vercel). +- It moves crest's architecture toward the dominant ecosystem (ai-sdk + Node), making future feature pickup (structured-output schemas, tool middleware, multi-modal inputs) cheaper. + +Do not do Option A under any circumstance — moving keys to the renderer is the only outcome whose cost is hard to walk back. B and C exist as fallbacks if Option D is blocked (e.g. team decides to deprecate Electron in favor of a web frontend, in which case "Electron main" disappears and the calculus changes). + +## 8. Trigger conditions for actually scheduling this + +Don't schedule Option D today (the P0-#1 through P0-#5 items just landed; ride those for a release or two and see what falls out). Schedule it when **any** of the following hits: + +- A new major LLM provider goes on the roadmap and would require adding another ~700 LOC Go backend to `pkg/aiusechat/`. +- A bug touches the same per-vendor SSE/tool-translation logic in 2+ backends within a quarter. +- ai-sdk adds a feature crest wants (streaming object schemas, server-side tools, multi-modal inputs) and rolling it ourselves would mean ~300+ LOC across all 4 backends. +- We start losing more than ~2 days/quarter to provider-quirk bugs in `pkg/aiusechat/`. +- We have a 2–3 week stretch where the core team isn't load-bearing on user-visible features (D is internal restructuring; doesn't ship a feature directly). + +## 9. Minimal Option D execution plan + +Once scheduled, the work splits cleanly: + +**Week 1 — main-side agent skeleton** +1. Create `emain/agent/` with: ai-sdk transport, conversation state, tool dispatcher, secret/chat persistence shims (initially calling wavesrv via wshrpc for both). +2. Stand up a parallel IPC channel for `useChat` (`electron-main:agent-stream`) without removing the wavesrv HTTP path yet. +3. Smoke test: `/api/post-agent-message` and the new IPC channel can both serve the same renderer. + +**Week 2 — vendor swap-in + tool migration** +4. Replace the 4 wavesrv backends with `@ai-sdk/openai` / `@ai-sdk/anthropic` / `@ai-sdk/google` / `@openrouter/ai-sdk-provider` in main. +5. Migrate tools that don't need wavesrv (filesystem ops, git, web fetch, shell exec via Node `child_process`) into main. +6. Keep wavesrv-required tools (block snapshot, PTY input) as wshrpc calls from main. + +**Week 3 — cutover + cleanup** +7. Flip `useChat` transport to the IPC channel; delete `/api/post-agent-message` and `pkg/aiusechat/*`. +8. Move chatstore + checkpointstore to main-side SQLite + filesystem. +9. Decide secretstore destination (see §5 question 1). +10. Run bench harness for parity check across providers. +11. Update `docs/agent-architecture.md` to reflect the new boundary. + +The order matters: stand the new path up alongside the old, prove parity, then delete. No big-bang flip. + +--- + +## tl;dr + +The "FE vs BE for LLM client" framing produces a stuck tradeoff because it accepts the wrong premise — that the agent loop has to live in Go wavesrv. Every other Electron AI product runs the agent in the main process; doing the same dissolves the tradeoff. **Option D (agent in Electron main + ai-sdk providers in Node) is the path that matches industry practice and scores cleanly on every dimension that Options A/B/C trade off against each other.** Don't schedule it this quarter, but schedule it when one of the §8 triggers fires. diff --git a/docs/buzzing-purring-crescent.md b/docs/buzzing-purring-crescent.md new file mode 100644 index 000000000..b00d49ed2 --- /dev/null +++ b/docs/buzzing-purring-crescent.md @@ -0,0 +1,292 @@ +# Crest Native Go Coding Agent — Implementation Plan + +## Context + +Crest currently has two AI surfaces: the legacy `WaveAI` side panel (`frontend/app/aipanel/`) and the already-shipped `:` terminal overlay (`frontend/app/view/term/term-agent.tsx`). Both point at `/api/post-chat-message`, which runs the `pkg/aiusechat` step loop with a generic chat system prompt and a small built-in tool set. + +We want a **native Go coding agent** — inspired by ForgeCode (Rust, Apache 2.0) but not integrated as a sidecar — that: + +- Runs inside Crest as a first-class capability, reusing `pkg/aiusechat` as the mechanism layer (providers, SSE stream, tool protocol, approval registry). +- Ships three Forge-style modes from MVP: `:ask` (read-only research), `:plan` (produce a plan doc), `:do` (execute changes via Crest primitives, visible as blocks). +- Treats agent actions — especially shell execution — as **first-class Crest blocks** the user can observe and interact with, rather than hidden subprocesses. +- Reserves hooks for Phase 2 browser automation (CDP over webview blocks) and MCP/skills integration. +- Preserves Apache 2.0 attribution to ForgeCode for any extracted prompts. + +The `:` overlay, AI-SDK v5 streaming, tool-call UI, and approval UI all exist and must be reused, not rewritten. + +## Package Layout — `pkg/agent/` + +``` +pkg/agent/ +├── agent.go — RunAgent(ctx, sse, opts) → wraps aiusechat.RunAIChat +├── modes.go — Mode struct, ApprovalPolicy, ModeAsk/ModePlan/ModeDo, LookupMode +├── session.go — Session struct (chatId, tabId, blockId, mode, cwd, connection, recentCmds) +├── context.go — BuildTerminalContext(sess) → <terminal_context> block for system prompt +├── http.go — PostAgentMessageHandler + PostAgentMessageRequest +├── registry.go — ToolRegistry, ToolsForMode, namespace reservation (browser.*) +├── chatstore.go — namespaced wrapper around chatstore.DefaultChatStore ("agent:"+chatId) +├── prompts.go — //go:embed prompts/*.md loader +├── prompts/ +│ ├── shared_header.md +│ ├── ask.md +│ ├── plan.md +│ ├── do.md +│ └── UPSTREAM.md — pinned ForgeCode commit SHA + re-sync notes +├── NOTICE — Apache 2.0 attribution to tailcallhq/forgecode +├── tools/ +│ ├── read_file.go — adapter: pkg/aiusechat/tools_readfile.go +│ ├── list_dir.go — adapter: pkg/aiusechat/tools_readdir.go +│ ├── write_file.go — adapter: pkg/aiusechat/tools_writefile.go +│ ├── edit_file.go — adapter: pkg/aiusechat/tools_writefile.go (edit_text_file) +│ ├── get_scrollback.go — adapter: pkg/aiusechat/tools_term.go +│ ├── cmd_history.go — NEW: cmdblock.GetByBlockID + filestore.WFS.ReadAt +│ ├── shell_exec.go — NEW: creates cmd-block, streams output, waits completion +│ ├── write_plan.go — NEW: writes markdown to <cwd>/.crest-plans/<slug>.md + opens preview block +│ ├── create_block.go — NEW: thin wrapper on CreateBlockCommand +│ ├── focus_block.go — NEW: thin wrapper on SetBlockFocusCommand +│ └── browser_stub.go — NEW: reserves browser.{navigate,screenshot,click,read_text} names, returns "not yet available" +``` + +### Public entrypoint + +```go +// pkg/agent/agent.go +type AgentOpts struct { + Session *Session + Mode *Mode + UserMsg *uctypes.AIMessage + AIOpts uctypes.AIOptsType +} + +func RunAgent(ctx context.Context, sse *sse.SSEHandlerCh, opts AgentOpts) (*uctypes.AIMetrics, error) +``` + +`RunAgent` composes `uctypes.WaveChatOpts{ChatId, Config, Tools, SystemPrompt, TabId}`: +- `Tools = registry.ToolsForMode(opts.Mode, opts.Session)` +- `SystemPrompt = []string{shared_header, opts.Mode.SystemPrompt, context.BuildTerminalContext(opts.Session)}` +- `TabStateGenerator` is left nil (terminal context is static per request; per-step dynamic tools aren't needed in MVP) + +Calls `aiusechat.RunAIChat(ctx, sse, backend, chatOpts)` with `backend := aiusechat.GetBackendByAPIType(opts.AIOpts.APIType)`. + +**Division of concerns (enforced):** +- `pkg/agent` = policy (modes, approval rules, prompts, terminal-aware tools) +- `pkg/aiusechat` = mechanism (providers, stream protocol, step loop, approval registry) +- `pkg/aiusechat` MUST NOT import `pkg/agent`. Documented in `pkg/agent/README.md`. + +## Mode Contract + +```go +// pkg/agent/modes.go +type ApprovalPolicy struct { + AutoApproveAll bool + AutoApproveTools map[string]bool // tool name → auto + AutoApprovePathGlobs []string // e.g. ".crest-plans/**" + RequireApproval map[string]bool // explicit deny-auto +} + +type Mode struct { + Name string // "ask" | "plan" | "do" + DisplayName string + SystemPrompt string // embedded from prompts/<name>.md + ToolNames []string // resolved against registry; supports globs (e.g. "browser.*") + AllowMutation bool + Approval ApprovalPolicy + StepBudget int // default 40 + FailureBudget int // default 3 consecutive tool failures +} +``` + +### Seed modes + +| Mode | Tools | Approval | Mutation | +|---|---|---|---| +| `ask` | `read_file`, `list_dir`, `get_scrollback`, `cmd_history` | `AutoApproveAll: true` | false | +| `plan` | `read_file`, `list_dir`, `get_scrollback`, `cmd_history`, `write_plan` | `AutoApprovePathGlobs: [".crest-plans/**"]` | false (plans only) | +| `do` | `read_file`, `list_dir`, `get_scrollback`, `cmd_history`, `write_file`, `edit_file`, `shell_exec`, `create_block`, `focus_block` | `RequireApproval: {write_file, edit_file, shell_exec, create_block}` = true; reads auto | true | + +Each tool's `ToolDefinition.ToolApproval` callback consults `mode.Approval` and returns `ApprovalAutoApproved` or `ApprovalNeedsApproval`. The existing aiusechat step loop + `toolapproval.go` handle the rest — no changes to provider subpackages. + +## The `shell_exec` Tool — Crest-Native Differentiator + +This is the critical tool. Agent shell actions are **visible to the user as blocks**, not hidden subprocesses. + +```go +type ShellExecInput struct { + Cmd string `json:"cmd"` + Cwd string `json:"cwd,omitempty"` + TimeoutSec int `json:"timeout_sec,omitempty"` // default 120, max 600 + CloseOnExit bool `json:"close_on_exit,omitempty"` +} + +type ShellExecOutput struct { + BlockId string `json:"block_id"` // user-visible, deep-linkable + ExitCode int `json:"exit_code"` + DurationMs int64 `json:"duration_ms"` + StdoutTail string `json:"stdout_tail"` // last ~8 KiB, ANSI-stripped, UTF-8 repaired + Truncated bool `json:"truncated"` + TimedOut bool `json:"timed_out"` +} +``` + +**Execution flow** (inside `ToolAnyCallback`): + +1. Gate on `do` mode approval policy. +2. Resolve cwd: input → `sess.Cwd` → parent block's `cmd:cwd` meta. +3. Build `waveobj.BlockDef{Meta: {view:"term", controller:"cmd", cmd:<Cmd>, "cmd:cwd":<cwd>, "cmd:runonstart":"true", "cmd:closeonexit":<CloseOnExit>}}` with connection from `sess.Connection`. +4. `wcore.CreateBlock(ctx, sess.TabId, blockDef, nil)` — returns new blockId. +5. Layout action: `LayoutActionData{ActionType: SplitVertical, TargetBlockId: sess.BlockId, Position:"after"}` — same pattern as `wshserver.CreateBlockCommand` (wshserver.go:267-280). +6. `blockcontroller.ResyncController(ctx, sess.TabId, newBlockId, nil, false)` to start. +7. Subscribe to `wps.Event_ControllerStatus` filtered by `newBlockId`; emit `ToolProgressDesc` with live status (`"running in block <id>, <s>s elapsed"`) so the overlay shows streaming progress via existing `data-toolprogress`. +8. Wait on completion: `cmdblock.LatestForBlock` polling with small backoff, or event subscription, until `state == StateDone` or ctx deadline. +9. Read tail: `filestore.WFS.ReadAt(newBlockId, "blockfile:term", cmdBlock.OutputStartOffset, size)` with 8 KiB window; strip ANSI; repair UTF-8; set `Truncated` if more bytes exist. +10. Populate `UIMessageDataToolUse.BlockId = newBlockId` so the overlay's tool-use chip can deep-link. + +**Timeout**: on `ctx.Done()`, `blockcontroller.SendInput(newBlockId, &BlockInputUnion{InputData: "\x03"})` (SIGINT); if still running after 3 s, `blockcontroller.DestroyBlockController(newBlockId)`; mark `TimedOut`. + +## Frontend Changes + +All paths under `frontend/app/view/term/`. + +### `term-model.ts` + +- **New atom** (next to `termAgentChatId` at ~line 126): + ```ts + termAgentModeAtom: jotai.PrimitiveAtom<"ask" | "plan" | "do"> + ``` + Default: `"do"`. Initialized in `TermViewModel` constructor. + +- **`handleTermAgentKeydown` (line 872)**: when composer opens and input begins with `:ask `, `:plan `, or `:do `, strip prefix and set `termAgentModeAtom`. Bare `:` opens composer with mode `do`. + +- **`submitTermAgentPrompt` (~line 643)**: read `globalStore.get(this.termAgentModeAtom)` and include as `mode` in the request body, plus `cwd`, `connection`, `last_command` (already computed around lines 613-617 in `buildTermAgentPrompt`). + +### `term-agent.tsx` + +- **`DefaultChatTransport` (line 197)**: change `api` to `${getWebServerEndpoint()}/api/post-agent-message`. +- **`prepareSendMessagesRequest` (line 198)**: add `mode`, `blockid: model.blockId`, `context: {cwd, connection, last_command, recent_cmds}` to body. +- **Overlay header (~line 239)**: small mode chip ("ask" / "plan" / "do") reading from `termAgentModeAtom`. +- **No changes to message rendering** — AI-SDK v5 stream parts (`text`, `reasoning`, `data-tooluse`, `data-toolprogress`) are emitted by aiusechat's existing SSE writer. Approval UI (`TermAgentApprovalButtons` → `RpcApi.WaveAIToolApproveCommand`) works unchanged. + +### No changes needed + +- `frontend/app/aipanel/` — existing WaveAI panel continues to use `/api/post-chat-message` and is untouched. + +## Request / Response + +```go +// pkg/agent/http.go +type PostAgentMessageRequest struct { + ChatID string `json:"chatid"` // required UUID + TabId string `json:"tabid"` + BlockId string `json:"blockid"` + Mode string `json:"mode"` // "ask" | "plan" | "do" + Msg uctypes.AIMessage `json:"msg"` + AIMode string `json:"aimode"` // provider selection (waveai@balanced etc.) + Context AgentContext `json:"context,omitempty"` +} + +type AgentContext struct { + Cwd string `json:"cwd,omitempty"` + Connection string `json:"connection,omitempty"` + LastCommand string `json:"last_command,omitempty"` + RecentCmds []string `json:"recent_cmds,omitempty"` // up to 20 entries +} +``` + +**Handler flow** (`PostAgentMessageHandler`, modeled on `aiusechat.WaveAIPostMessageHandler` at usechat.go:635): +1. Decode + validate UUID chatid. +2. `modes.LookupMode(req.Mode)` — 400 on unknown. +3. Resolve `AIOptsType` (reuse aiusechat's settings resolution — factor a small helper if not already exported). +4. Build `Session{ChatID, TabId, BlockId, Mode, Cwd, Connection, LastCommand, RecentCmds}`. +5. `sse.MakeSSEHandlerCh(w, r.Context())`. +6. `agent.RunAgent(ctx, sse, AgentOpts{...})`. + +**Response**: existing AI-SDK v5 SSE stream produced by `aiusechat` — no new format. + +**Route registration** in `pkg/web/web.go` beside line 454: +```go +gr.HandleFunc("/api/post-agent-message", WebFnWrap(WebFnOpts{AllowCaching: false}, agent.PostAgentMessageHandler)) +``` + +## Browser Tool Hook (MVP Placeholder) + +- `pkg/agent/registry.go`: `const BrowserToolNamespace = "browser"`. Reserve `browser.navigate`, `browser.screenshot`, `browser.click`, `browser.read_text`. +- `pkg/agent/tools/browser_stub.go`: registers each reserved name with `ToolAnyCallback` returning `{"error": "browser tools require Phase 2 enablement"}` and `RequiredCapabilities: ["browser"]` so `HasRequiredCapabilities` filters them from the default tool list until enabled. +- `const ApprovalCategoryBrowser = "browser"` so Phase 2 can slot a category-grouped approval UI without schema migration. +- `Mode.ToolNames` supports globs (`"browser.*"`); no mode uses it in MVP. + +## Phase Breakdown + +### Phase 1 — MVP (~4 weeks) + +- **Week 1**: `pkg/agent/` skeleton, `modes.go`, `prompts/` (extract + rewrite three Forge prompts, preserve attribution), HTTP handler registered, chatstore namespacing. +- **Week 2**: tool registry + read-only adapters (`read_file`, `list_dir`, `cmd_history`, `get_scrollback`, `write_plan`). End-to-end `:ask` and `:plan`. +- **Week 3**: `shell_exec`, `write_file`/`edit_file` adapters, `create_block`, `focus_block`. `:do` end-to-end with approvals. Browser stub. +- **Week 4**: frontend mode chip + prefix parsing, `NOTICE` attribution, telemetry wiring (reuse `recordChatEvent`), test coverage. + +### Phase 2 (~4 weeks) + +- Browser tool implementation — new RPC `WebInteractCommand` with CDP via `webContents.debugger` (or injected JS for click/fill MVP), slotted into reserved registry. +- External MCP client (stdio + SSE), tool enumeration + dynamic registration. +- Skills integration: expose `.kilocode/skills/` as agent-invokable library. +- Refined prompts + approval policies based on dogfood signal. +- Eval harness: replay golden transcripts + sample terminal-bench tasks. + +### Phase 3 (stretch) + +- Git worktree sandboxing for `:do`. +- Conversation export / import (`.crest-agent.json`). +- Local embedding-based repo search (replacing Forge's `api.forgecode.dev` dependency). +- Multi-block coordinated tasks, plan → execution handoff. + +## Verification + +### Unit tests + +- `pkg/agent/modes_test.go` — mode lookup, tool filtering, approval policy resolution. +- `pkg/agent/tools/shell_exec_test.go` — spawn test tab + block via `wcore.CreateBlock`, run `echo hi`, assert `exit_code=0`, stdout tail non-empty; timeout path (`sleep 10` with 1 s timeout). +- `pkg/agent/tools/cmd_history_test.go` — seed cmdblock rows, assert query shape. +- `pkg/agent/http_test.go` — POST validation (missing chatid, invalid mode, malformed context). + +### Existing suites must pass unchanged + +- `pkg/aiusechat/...`, `pkg/blockcontroller/...`, `pkg/cmdblock/...`, `pkg/wcore/...`, `pkg/aiusechat/tools_readdir_test.go`, `pkg/aiusechat/usechat_mode_test.go`. + +### Manual E2E + +- `:ask how is logging wired here` → markdown response, no filesystem writes, no approval banners. +- `:plan add retry to RunAIChat` → writes `.crest-plans/add-retry-to-runaichat.md`, opens preview block next to terminal. +- `:do run the unit tests` → agent calls `shell_exec("go test ./pkg/agent/...")`, approval chip appears in overlay, user approves, new cmd-block appears adjacent, exit code + tail returned to chat. +- `:do` with denied approval → tool returns structured rejection, agent continues or asks. +- Background-tab approval: issue a `:do` in tab A, switch to tab B → approval surfaces via notification toast (existing `NotificationsModel` path). +- Regression: bare `:` in overlay still works with mode `do` default. + +## Critical Files + +**Modify:** +- `pkg/web/web.go:454` — register new route. +- `frontend/app/view/term/term-model.ts` — mode atom, prefix parsing, submit includes mode. +- `frontend/app/view/term/term-agent.tsx` — transport endpoint, request body, mode chip. + +**Create:** +- Entire `pkg/agent/` package (layout above). +- `pkg/agent/NOTICE` + root `NOTICE` addition. + +**Read / reference (do not modify):** +- `pkg/aiusechat/usechat.go` — step loop + handler pattern. +- `pkg/aiusechat/uctypes/uctypes.go` — `ToolDefinition`, `WaveChatOpts`, stream types. +- `pkg/aiusechat/toolapproval.go` — approval registry. +- `pkg/aiusechat/tools_readfile.go` / `tools_readdir.go` / `tools_writefile.go` / `tools_term.go` — adapter sources. +- `pkg/blockcontroller/blockcontroller.go` — `ResyncController`, `SendInput`, `GetBlockControllerRuntimeStatus`, `DestroyBlockController`. +- `pkg/cmdblock/store.go` — `GetByBlockID`, `LatestForBlock`. +- `pkg/wcore/block.go` — `CreateBlock`. +- `pkg/wshrpc/wshserver/wshserver.go:229` — `CreateBlockCommand` reference pattern. + +## Risks & Open Questions + +- **Forge prompt drift**: pin ForgeCode commit SHA in `pkg/agent/prompts/UPSTREAM.md`; quarterly re-sync cadence; `scripts/agent/diff-forge-prompts.sh` to help the diff review. +- **License attribution mechanics**: `pkg/agent/NOTICE` + top-of-file comment in each `prompts/*.md`; root `NOTICE` references it. Apache 2.0 preserved. +- **`pkg/agent` vs `pkg/aiusechat` overlap**: split documented in `pkg/agent/README.md` as "policy vs mechanism"; a CI lint forbids `pkg/aiusechat` importing `pkg/agent`. +- **Endpoint convergence**: `/api/post-chat-message` remains for WaveAI panel; `/api/post-agent-message` for terminal agent. Convergence = Phase 3 decision behind a feature flag. +- **chatstore isolation**: use key prefix `"agent:"` to avoid conversation cross-contamination when a user uses both surfaces simultaneously. +- **Open**: whether `write_plan` should create a Crest preview block automatically or only write the file (user opens manually). Default to auto-open for discoverability; revisit after dogfood. +- **Open**: max `recent_cmds` to inject — 20 entries × ~200 chars = ~4 KB, acceptable for all providers. Reconsider if token budget becomes tight. diff --git a/docs/claude-code-parity.md b/docs/claude-code-parity.md new file mode 100644 index 000000000..088b9c9b0 --- /dev/null +++ b/docs/claude-code-parity.md @@ -0,0 +1,231 @@ +# Claude-Code Parity — Optimization Tracking + +Living document for the Crest native agent → Claude Code parity sprint. +Companion to [`agent-optimization-roadmap.md`](./agent-optimization-roadmap.md) +(broader vision); this one tracks the **current optimization effort** with +phase-by-phase status, completed work, and what's queued next. + +Reference: `/Users/user/Documents/Claude-Code/` source. + +--- + +## Status Snapshot + +| # | Workstream | Status | Notes | +|---|---|---|---| +| 1 | Reliability hardening (Phases 1-4) | ✅ shipped | commit `0ce9f60b` — see §1 | +| 2 | Context Governance v2 (remainder) | ✅ shipped | context collapse + richer summary — see §2 | +| 3 | Permissions v2 (design) | ✅ design approved | see [`permissions-v2-design.md`](./permissions-v2-design.md); ready to implement | +| 4 | Agent Task Runtime v2 | âŗ queued | background tasks, lifecycle, UI surface — see §4 | +| 5 | Command Layer v1 | âŗ queued | real slash commands, autocomplete — see §5 | +| 6 | Memory System (P1) | 📋 planned | hierarchical CLAUDE.md, auto-extract — see §6 | +| 7 | MCP v2 (P1) | 📋 planned | resources, auth, reconnect — see §7 | +| 8 | ToolčĄĨéŊ (P1) | 📋 planned | LSP, web search — see §8 | + +Legend: ✅ done ¡ 🚧 in progress ¡ 📐 designing ¡ âŗ queued ¡ 📋 planned + +--- + +## §1 — Reliability Hardening (shipped, commit `0ce9f60b`) + +Eleven concrete gaps closed across four phases. Source pointers below trace +each item back to the Claude Code reference behavior we matched. + +### Phase 1 — API Reliability + +| Item | Crest file | Claude Code reference | +|---|---|---| +| 1.1 API retry w/ exp backoff (was already done) | `pkg/aiusechat/httpretry/httpretry.go` | `src/services/api/withRetry.ts` | +| 1.2 Max-tokens recovery (escalate → resume×3) | `pkg/aiusechat/usechat.go` (loop) | `src/query.ts` max-output-tokens recovery | +| 1.3 Reactive compact on context-length errors | `pkg/aiusechat/usechat.go` (loop) | `src/services/compact/reactiveCompact.ts` | + +### Phase 2 — Context Management + +| Item | Crest file | Claude Code reference | +|---|---|---| +| 2.1 Tool result spill to disk | `pkg/aiusechat/tool_spill.go` (new) | `src/services/toolResultBudget` | +| 2.2 Microcompact tier (60% threshold) | `pkg/aiusechat/usechat.go` (loop) | `src/services/compact/microCompact.ts` | + +### Phase 3 — System Prompt & Tool Structure + +| Item | Crest file | Claude Code reference | +|---|---|---| +| 3.1 Per-tool `Prompt` field + populated for 6 tools | `pkg/aiusechat/uctypes/uctypes.go`, `pkg/agent/tools/*.go` | each tool's `prompt.ts` | +| 3.2 Static/dynamic prompt boundary | `pkg/agent/agent.go` | `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` | +| 3.3 "Executing actions with care" section | `pkg/agent/prompts/shared_header.md` | `getActionsSection` | + +### Phase 4 — Subagent & Safety + +| Item | Crest file | Claude Code reference | +|---|---|---| +| 4.1 spawn_task returns final assistant text | `pkg/agent/tools/spawn_task.go` | `src/tools/AgentTool/runAgent.ts` | +| 4.2 File mtime tracking (refuse stale edits) | `pkg/agent/tools/file_tracker.go` (new) | `FILE_UNEXPECTEDLY_MODIFIED_ERROR` | +| 4.3 Tool error classification (`ErrorType`) | `pkg/aiusechat/uctypes/uctypes.go`, `usechat.go` | `classifyToolError` | + +--- + +## §2 — Context Governance v2 (shipped) + +Three escalating tiers in the chat loop, picked by % of `ContextBudget`: + +| Tier | Threshold | Action | Where | +|---|---|---|---| +| collapse | 50% | replace old tool result *content* with a placeholder, keep messages | `chatstore.CollapseOldToolResults` + per-backend `CollapseToolResults` | +| microcompact | 60% | drop whole older messages, no summary | `chatstore.CompactMessages` | +| heavy | 80% | drop + inject summary including files modified | `chatstore.CompactMessagesWithSummary` + `extractTouchedFilesFromAudit` | + +### 2a. Context collapse + +`ToolResultCollapsible` interface added to `uctypes`; implemented on +each backend's tool-result-bearing message type: +- Anthropic: rewrites `tool_result` block `Content` field +- OpenAI Chat: rewrites `role:"tool"` message `Content` +- Gemini: rewrites `functionResponse.Response` to `{"output": placeholder}` +- OpenAI Responses: rewrites `function_call_output.Output` + +ToolUseID / call_id / function name preserved so tool_use ↔ tool_result +pairing stays valid. Tier runs at 50% of `ContextBudget`, keepLast=15. + +### 2b. Richer compaction summary + +`extractTouchedFilesFromAudit` walks `metrics.AuditLog` for write/edit/ +multi_edit calls, parses `filename` from InputArgs (with truncation +recovery for cut-off JSON), and the heavy-tier summary now appends +`Files modified during this conversation: ...` so the model retains a +list of files it has worked on across compaction. + +--- + +## §3 — Permissions v2 + +**Status:** design doc approved 2026-04-27 — see +[`permissions-v2-design.md`](./permissions-v2-design.md). All 4 open +questions resolved; ready to implement. + +**Highlights of the design:** +- Rule grammar: tool-name + content matcher (`shell_exec(prefix:npm)`, + `edit_text_file(/path/**)`); path globs + shell prefixes + exact +- Five scopes (highest precedence first): cliArg → user → + sharedProject (`.crest/permissions.json`) → localProject + (`.crest/permissions.local.json`) → session +- Modes become rule presets, not parallel rule namespaces — one rule + pool, modes flip default posture +- **Mode and Permission Posture are split into two orthogonal axes** + (this was the bigger structural lesson from Claude Code's design): + - Mode (work axis): `ask` / `plan` / `do` — visible in the mode + picker. `bench` is a fourth mode but hidden, API-only, used by + eval harnesses. + - Posture (strictness axis): `default` / `acceptEdits` / + `bypassPermissions` — toggled via Shift+Tab cycle or + `/permission` slash command. Status pill shows current posture in + the agent overlay header. Bench mode implicitly forces a hidden + `bench` posture that skips even bypass-immune safety checks. + - Posture is per-chat state with `defaultPosture` setting for new + chats. **Bundled default is `acceptEdits`** (diverges from Claude + Code's `default` default — Crest's audience is personal + local-coding work where iterative edits dominate). Risk bounded + by file backups, mtime tracking, bypass-immune paths, deny rules, + and `shell_exec` still prompting. + - Posture only affects calls the rule engine would otherwise + *ask* about — bypass-immune paths still prompt under any + user-selectable posture. +- Bypass-immune safety checks: `.git/`, `.ssh/`, `.env`, `rm -rf /`, + `curl|sh`, `sudo`, etc. force a prompt in `bypassPermissions`. +- Approval prompt default "save to" = `session` (least commitment). +- v1 ships **without** classifier (defer to v2); without + PermissionRequest hooks (no hook framework yet); without policy tier + +**Implementation order in design doc §5.3.** ETA: 2 sittings for core +(parser → matchers → store → engine → tool adapters → wire-in), 1 +sitting for UI polish + defaults. + +--- + +## §4 — Agent Task Runtime v2 (queued) + +**Goal:** spawn_task today is one-shot synchronous. Claude has full +agent-task lifecycle: background, status, stop, continue, monitor. + +**Backend work:** +- Task registry (`pkg/agent/taskregistry/`) keyed by task ID, with + status transitions: pending → running → completed/canceled/error. +- Lifecycle: start, stop, query, list. Background tasks run in their + own goroutine + context. +- Persistence: at least in-memory across the process; persist to disk + out of scope for v1. +- New tools: `task_get`, `task_list`, `task_stop`, `task_output`. + +**Frontend work (~50% of effort):** +- Tasks panel UI (sidebar or overlay tab) showing live tasks. +- Stop/continue buttons per task, output preview. +- Notification when a background task finishes. + +**Claude reference:** +- `src/tasks/LocalAgentTask/LocalAgentTask.tsx` +- `src/tools/AgentTool/AgentTool.tsx` (background mode) + +**Decision needed:** is this worth shipping without the UI? Backend +alone is 70% of code but 0% of user-visible value. + +--- + +## §5 — Command Layer v1 (queued) + +**Goal:** real command palette like Claude's slash commands. Today the +overlay does prefix detection (`ask:`, `plan:`, `do:`) — no +autocomplete, no help, no plugin commands. + +**Scope for v1:** +- Command registry with `name`, `description`, `args`, `handler`. +- Autocomplete on `/<typing>` showing matching commands. +- `/help` lists all commands. +- Plugin command discovery from a known directory. +- Built-in commands: `/help`, `/clear`, `/model`, `/mode`, `/undo`, + `/worktree` (some already exist as ad-hoc handlers — unify). + +**Out of scope for v1:** plugin auth, hot-reload, command parameters +beyond positional strings. + +**Claude reference:** `src/commands.ts:258`, `src/utils/plugins/loadPluginCommands.ts` + +--- + +## §6-8 — P1 (planned, deferred) + +- **Memory:** hierarchical CLAUDE.md discovery + auto-memory extraction + via forked-agent. We have flat project guidelines today; Claude's + layered system is large but high-leverage. +- **MCP v2:** resources read/write, OAuth flows, reconnect logic. Today + it's stdio-only with manual config. +- **ToolčĄĨéŊ:** LSP integration (definition, references, diagnostics), + first-class `web_search` (currently only `web_fetch`). + +--- + +## Decisions Log + +| Date | Decision | Why | +|---|---|---| +| 2026-04-27 | Skip token-budget continuation (Claude `+500k` syntax) | Niche; pending-todos nudge already covers the practical case | +| 2026-04-27 | Microcompact via message deletion, not content rewrite | Backend-agnostic; `MessageDependsOnPrev` already preserves tool pair integrity | +| 2026-04-27 | spawn_task returns final assistant text via `ConvertAIChatToUIChat` | Avoids needing a new method on the Backend interface | +| 2026-04-27 | Context collapse via opt-in `ToolResultCollapsible` interface, not a Backend method | Same pattern as `MessageDependsOnPrev`; only the message types that carry tool results need to opt in, no new method on the Backend interface | +| 2026-04-27 | Heavy-summary file list pulled from `metrics.AuditLog`, not chatstore-tracked | Audit log already exists; threading it into chatstore would need an interface change for marginal benefit | +| 2026-04-27 | Permissions v2 — `bench` and `bypassPermissions` are distinct modes | They look similar (auto-approve) but serve different audiences. Bench has *no* safety checks (eval harness can do whatever); bypass keeps bypass-immune ones (interactive "trust me"). Bench hidden from user picker so a casual user can't trip into a no-safety mode. | +| 2026-04-27 | Permissions v2 — Mode and Posture are orthogonal axes | Conflating them (single picker with `ask`/`plan`/`do`/`bypassPermissions`) mixes work mode with permission strictness. Split: Mode = tools/prompt/budget; Posture = strictness. Posture set: `default` / `acceptEdits` / `bypassPermissions` + hidden `bench`, toggled via Shift+Tab. | +| 2026-04-27 | Permissions v2 — bundled default posture is `acceptEdits` | Diverges from Claude Code's `default` default. Crest's audience is personal local-coding workflows; clicking through every edit approval is too cumbersome. Risk bounded by file backups, mtime tracking, bypass-immune paths still prompting, `shell_exec` still prompting, and deny rules still firing. | +| 2026-04-27 | Permissions v2 — rules in standalone `pkg/agent/permissions` package | Clean cycle story; engine reusable beyond the agent loop | +| 2026-04-27 | Permissions v2 — defer classifier to v2 | Rules-first ships value sooner; classifier needs prompt design + gating | + +--- + +## Working Notes + +- Each shipped item should match a pointer in this doc's status table — + if it doesn't, either the doc is stale or the work was unscoped. +- Before adding any new "should match Claude" item, check whether it + pays off for the *terminal* use case. Some Claude features (REPL + mode, push notifications, cron) make sense for a CLI agent but not + necessarily for Crest's terminal-block model. +- Keep this doc < 250 lines. Detail belongs in feature-specific design + docs (e.g. `permissions-v2-design.md`) — this is an index. diff --git a/docs/examples/README.md b/docs/examples/README.md new file mode 100644 index 000000000..cc42f2100 --- /dev/null +++ b/docs/examples/README.md @@ -0,0 +1,34 @@ +# Crest Configuration Examples + +Working, copy-pasteable starting points for the user-editable config files. + +| File | Drop into | What it's for | +|---|---|---| +| `ai.json.example` | `~/.config/crest/ai.json` | AI provider credentials, default model selection, named profiles, custom endpoints. Reference: [`../agent-user-guide.md`](../agent-user-guide.md) § "AI Provider Configuration". | + +## `ai.json.example` — minimal vs full + +The shipped example is the **full** shape — every section populated to show what's possible. **Strip the parts you don't need before saving.** A working minimal `ai.json` is just: + +```jsonc +{ + "providers": { + "openai": { "tokensecretname": "OPENAI_API_KEY" } + }, + "default": { + "provider": "openai", + "model": "gpt-5" + } +} +``` + +That's the smallest valid config — one provider with credentials, one default selection. + +## Schema rules + +- `providers` and `default` are **required**. Everything else is optional. +- `providers` keys must be either a catalog provider id (`openai`, `anthropic`, `google`, `openrouter`) or a key from `custom_endpoints`. +- `default.provider` + `default.model` must resolve to a real model (catalog, custom_models, or custom_endpoints). +- `tokensecretname` is preferred over inline `token`. The OS keychain stores the literal value, looked up by this name at request time. +- Empty string `"tokensecretname": ""` is valid and means "this endpoint accepts unauthed requests" (typical for local vLLM, LM Studio). +- Unknown top-level fields are rejected at parse time — typos surface as a "malformed config" banner in the picker. diff --git a/docs/examples/ai.json.example b/docs/examples/ai.json.example new file mode 100644 index 000000000..8b0b2c421 --- /dev/null +++ b/docs/examples/ai.json.example @@ -0,0 +1,49 @@ +{ + "providers": { + "openai": { "tokensecretname": "OPENAI_API_KEY" }, + "anthropic": { "tokensecretname": "ANTHROPIC_API_KEY" }, + "google": { "tokensecretname": "GOOGLE_AI_KEY" }, + "openrouter": { "tokensecretname": "OPENROUTER_API_KEY" } + }, + + "default": { + "provider": "openai", + "model": "gpt-5", + "reasoning": "medium" + }, + + "profiles": { + "fast": { "provider": "openai", "model": "gpt-5-mini" }, + "deepwork": { "provider": "anthropic", "model": "claude-opus-4-7", "reasoning": "high" }, + "cheap": { "provider": "google", "model": "gemini-2.0-flash" } + }, + + "custom_models": [ + { + "provider": "openai", + "id": "gpt-experimental", + "displayname": "GPT Experimental", + "description": "Bleeding-edge OpenAI model not yet in catalog", + "capabilities": ["tools"], + "contextwindow": 50000 + } + ], + + "custom_endpoints": { + "vllm-local": { + "displayname": "Local vLLM", + "endpoint": "http://localhost:8000/v1/chat/completions", + "apitype": "openai-chat", + "tokensecretname": "", + "icon": "server", + "models": [ + { + "id": "qwen-2.5-coder-32b", + "displayName": "Qwen 2.5 Coder 32B", + "capabilities": ["tools"], + "contextWindow": 128000 + } + ] + } + } +} diff --git a/docs/fix-block-focus-infinite-loop.md b/docs/fix-block-focus-infinite-loop.md new file mode 100644 index 000000000..07ea1dc45 --- /dev/null +++ b/docs/fix-block-focus-infinite-loop.md @@ -0,0 +1,87 @@ +# BlockFull focus 无限åžĒįŽ¯äŋŽå¤ + +**æ—Ĩ期**īŧš2026-04-22 +**文äģļ**īŧš`frontend/app/block/block.tsx` +**į—‡įŠļ**īŧšå¯åŠ¨åŽ React 抛 `Maximum update depth exceeded`īŧˆ`block.tsx:114` `setBlockClicked(isFocused)`īŧ‰īŧŒErrorBoundary æŽĨäŊīŧŒéĄĩéĸį™Ŋåąã€‚Console 里大量 +``` +setFocusedChild <blockId> input.termblocks-input +focusedChild focus <blockId> +``` +在两ä¸Ē/多ä¸Ē block 之间æĨ回打印。 + +## æ šå›  + +三ä¸Ē bug 叠加č§Ļ发īŧš + +1. **handleChildFocus æ— æĄäģļ调 `nodeModel.focusNode()`** + 上游提ä礿ŠŠ `if (!isFocused) focusNode()` įš„åŽˆåĢ删äē†ã€‚termblocks input 在挂čŊŊæ—ļ auto-focusīŧŒå¤šä¸Ē block įš„ input äē’ᛏæŠĸį„Ļį‚šīŧŒæ¯æŦĄ onFocus éƒŊč°ƒį”¨ focusNodeīŧŒčŽŠ layout model įš„ `focusedNodeId` 在几ä¸Ē node 间æĨå›žčˇŗã€‚ + +2. **useCallback é—­åŒ…é‡Œįš„ `isFocused` 是čŋ‡æœŸå€ŧ** + åŗäžŋčŋ˜åŽŸåވåĢ `if (!isFocused)`īŧŒDOM focus äē‹äģļ在同一ä¸Ē React tick 内同æ­Ĩčŋžįģ­č§Ļ发īŧŒReact čŋ˜æ˛ĄæĨåž—åŠé‡æ¸˛æŸ“åˆˇæ–° closure。handleChildFocus æ•čŽˇįš„ `isFocused` äģæ˜¯ä¸Šä¸€čŊŽįš„å€ŧīŧŒåވåĢčĸĢįģ•čŋ‡ã€‚ + +3. **įŦŦ一ä¸Ē `useLayoutEffect` ᔍ `setBlockClicked(isFocused)` 做中čŊŦ** + ```ts + useLayoutEffect(() => { + setBlockClicked(isFocused); + }, [isFocused]); + ``` + `isFocused` æŒ¯čĄ → 每æŦĄéƒŊ setState → č§Ļ发 re-render → 再æŦĄč¯ģåˆ°æŒ¯čĄįš„ `isFocused` → 再 setState。React 25 åą‚åŽæŠ› Max update depth。 + +éĸå¤–īŧš`TermBlocksInput` é‡Œæ–°åŠ įš„ `useEffect(() => inputRef.current?.focus(), [])` 加剧ä熿Œ‚čŊŊ期æŠĸį„Ļį‚šã€‚ + +## äŋŽå¤ + +### 1. handleChildFocus č¯ģ atom åŊ“åœēå€ŧīŧŒä¸į”¨é—­åŒ… + +```ts +const handleChildFocus = useCallback( + (event: React.FocusEvent<HTMLDivElement, Element>) => { + if (globalStore.get(nodeModel.isFocused)) { + return; + } + nodeModel.focusNode(); + }, + [nodeModel] +); +``` + +`globalStore.get()` č¯ģ atom įš„**åŊ“前å€ŧ**īŧŒįģ•čŋ‡ React 闭包åŋĢį…§ã€‚`nodeModel` į¨ŗåŽšīŧŒcallback 不再随 `isFocused` é‡æ–°į”Ÿæˆã€‚ + +### 2. įŦŦäēŒä¸Ē useLayoutEffect įš„åŽˆåĢåŒæ ˇæ”šæˆč¯ģ atom + +```ts +if (!globalStore.get(nodeModel.isFocused)) { + nodeModel.focusNode(); +} +``` + +### 3. įŦŦ一ä¸Ē useLayoutEffect åŊģåē•åŽģ掉 setStateīŧŒåĒ做 DOM focus + +```ts +useLayoutEffect(() => { + if (!isFocused) { + return; + } + const focusWithin = focusedBlockId() == nodeModel.blockId; + if (!focusWithin) { + setFocusTarget(); + } +}, [isFocused, nodeModel]); +``` + +`blockClicked` įŠļæ€ä¸å†į”ą `isFocused` å˜åŒ–éŠąåŠ¨īŧŒåĒį”ąį”¨æˆˇį‚šå‡ģīŧˆ`setBlockClickedTrue`īŧ‰č§Ļ发。įŦŦ一ä¸Ē effect įŽ°åœ¨**é›ļ setState**īŧŒäģŽæ šæœŦ上不可čƒŊ造成 React update-depth åžĒįŽ¯ã€‚ + +## æ•™čŽ­ + +- **Jotai æ´žį”Ÿ atom įš„č¯ģ取在äē‹äģļå¤„į†é‡ŒčρåŊ“åœēč¯ģ**īŧŒä¸čĻäžčĩ– useCallback é—­åŒ…įš„ propsīŧŒį‰šåˆĢ是 DOM 同æ­Ĩäē‹äģļ可čƒŊ在一ä¸Ē tick 内čŋžįģ­č§Ļå‘įš„åœē景。 +- **useLayoutEffect é‡Œį”¨ setState 做中čŊŦ厚易å‡ēåžĒįŽ¯**。åĻ‚æžœå¯äģĨį›´æŽĨ做 side effectīŧˆDOM 操äŊœīŧ‰īŧŒå°ąä¸čρįģ useState。 +- **删厈åĢäģŖį æ—ļčĻæƒŗæ¸…æĨšåވåĢåœ¨æŒĄäģ€äšˆ**。čŋ™æŦĄåˆ æŽ‰ `if (!isFocused)` į›´æŽĨ把æœŦæĨäē’æ–Ĩįš„į„Ļį‚šæĩå˜æˆäē†įŽ¯ã€‚ + +## ᛏ兺 commit / 攚动 + +- `frontend/app/block/block.tsx` — 3 处厈åĢ攚ä¸ē `globalStore.get(nodeModel.isFocused)`īŧ›įŦŦ一ä¸Ē layout effect åŽģ掉 setState +- 新åĸž importīŧš`import { globalStore } from "@/app/store/jotaiStore"` + +## å¤įŽ°æĄäģļīŧˆäŋŽå¤å‰īŧ‰ + +多 block workspaceīŧˆæœŦæŦĄ 3 block æœ€į¨ŗåŽšč§Ļ发īŧ‰īŧŒäģģ一 block ä¸ē termblocks č§†å›žīŧŒå¯åЍæ—ļ TermBlocksInput įš„ auto-focus `useEffect` 和 BlockFull įš„ focus pipeline äŧšįĢžäē‰ã€‚ diff --git a/docs/native-agent-progress.md b/docs/native-agent-progress.md new file mode 100644 index 000000000..64050d834 --- /dev/null +++ b/docs/native-agent-progress.md @@ -0,0 +1,138 @@ +# Crest Native Go Coding Agent — Progress Tracker + +Branch: `feat/native-agent` + +## Phase 1 — MVP ✅ + +### Core Implementation +- [x] `pkg/agent/` — 3 modes (ask/plan/do), 10 tools, HTTP handler, prompts, registry, session, context +- [x] `tools/shell_exec.go` — visible cmd-block, poll completion, ANSI-strip tail, SIGINT timeout +- [x] `tools/create_block.go` — term/preview/web block with split positioning +- [x] `tools/write_plan.go` — `.crest-plans/<slug>.md` + auto-open preview +- [x] Chatstore isolation (`"agent:"` prefix), telemetry source tagging +- [x] 38 unit tests across `pkg/agent/` and `pkg/agent/tools/` + +### Frontend Integration +- [x] Agent overlay integrated into `termblocks/` (the active terminal view) +- [x] `:` key opens composer on empty input, Esc closes overlay +- [x] Real `<input>` element with auto-focus, Enter/Esc handling +- [x] Mode chip (ask/plan/do) derived from input prefix +- [x] AI Provider settings UI in Settings sidebar + +### AI Provider Configuration +- [x] Visual form: provider dropdown, API key (OS keychain), model, advanced (base URL, max tokens) +- [x] `ai:apitokensecretname` added to SettingsType +- [x] Settings fallback: `resolveAgentAIOpts()` tries waveai mode system, falls back to `settings.json` +- [x] Full endpoint URLs for all providers + +### E2E Verified +- [x] `:ask hello` → AI response in overlay (OpenRouter) + +## Wave Legacy Cleanup ✅ + +| Step | Removed | Lines | +|------|---------|-------| +| `pkg/wcloud/` | Cloud telemetry upload | -396 | +| Preset system | `AiSettingsType`, preset files, schema | -241 | +| WaveAI panel | `aipanel/` 18 files + 30 downstream refs | -4542 | +| Cloud provider | `AIProvider_Wave`, X-Wave headers, rate limit, premium fallback | -490 | +| Cloud modes | `waveai@quick/balanced/deep`, mode broadcaster | -85 | +| Remaining artifacts | wsh view type, meta constants, telemetry fields | -41 | +| **Total** | | **~5800 lines removed** | + +## Phase 2 — MCP + Browser + +### MCP Client ✅ +- [x] `mcp-go` v0.49 dependency (stdio, SSE, streamable HTTP transports) +- [x] `MCPServerConfig` type + `ai:mcpservers` settings key +- [x] `pkg/agent/mcp/bridge.go` — MCP Tool → ToolDefinition conversion, `mcp__<server>__<tool>` namespacing +- [x] `pkg/agent/mcp/manager.go` — singleton MCPManager, lazy init, config watcher, server lifecycle +- [x] Wired into `ToolsForMode()` — MCP tools appended in mutation modes +- [x] App shutdown integration — `MCPManager.Shutdown()` in server doShutdown +- [x] System prompt updated with MCP tool guidance +- [x] 7 unit tests for bridge (name parsing, tool conversion, error handling, text extraction) + +### Skills Integration ✅ +- [x] `pkg/agent/skills.go` — discovers `.kilocode/skills/*/SKILL.md`, parses YAML frontmatter +- [x] `BuildSkillsContext()` injects `<available_skills>` block into system prompt +- [x] Agent auto-discovers 8 skills (add-config, add-rpc, add-wshcmd, context-menu, create-view, electron-api, waveenv, wps-events) +- [x] 7 unit tests (discovery, frontmatter parsing, context building, edge cases) + +### Browser Tools ✅ +- [x] `browser.navigate` — updates `meta.url` on web block, frontend re-renders `<webview>` +- [x] `browser.read_text` — reads DOM via `WebSelectorCommand` → `executeJavaScript()` +- [x] `browser.click` — clicks element via new `WebClickCommand` → Electron handler +- [x] `browser.screenshot` — captures webview viewport via new `WebScreenshotCommand` → `capturePage()` +- [x] New RPC types: `CommandWebClickData`, `CommandWebScreenshotData` +- [x] New Electron handlers: `handle_webclick`, `handle_webscreenshot` in `emain-wsh.ts` +- [x] `webClickElement()`, `webScreenshot()` helpers in `emain-web.ts` +- [x] All 4 tools wired into `ModeDo`, all require user approval +- [x] 10 unit tests (input parsing, tool definitions, capabilities) + +### Eval Harness ✅ +- [x] Golden transcript replay framework (`pkg/agent/eval/`) + - MockBackend implementing `UseChatBackend` — queued responses, real tool execution + - `RunGoldenTest()` engine — loads JSON transcripts, sets up temp workspace, runs with auto-approved tools + - `{{CWD}}` substitution for absolute paths in tool inputs + - `RunAllGoldenTests()` auto-discovers `*.golden.json` files + - 3 golden transcripts: `ask-read-file`, `ask-list-dir`, `do-write-file` + - 5 unit tests total +- [x] Terminal-bench 2.0 Harbor adapter (`eval/harbor/`) + - `CrestAgent` installed agent — builds `wavesrv` in container, POSTs to agent HTTP API + - Prompt template, ATIF trajectory output, README with usage docs + - Runnable via `harbor run --agent-import-path eval.harbor.crest_agent:CrestAgent` + +## Phase 3 — Stretch âŦœ + +- [ ] Git worktree sandboxing for `:do` +- [ ] Conversation export/import (`.crest-agent.json`) +- [ ] Local embedding-based repo search +- [ ] Multi-block coordinated tasks, plan → execution handoff +- [x] ~~Rename Go module path `wavetermdev/waveterm` → `s-zx/crest`~~ (done — 265 files, mechanical sed + regenerate) + +## Optimization (Tier 1 — production hardening) + +Tracking the prioritized roadmap in [`agent-optimization-roadmap.md`](./agent-optimization-roadmap.md). + +- [x] LLM retry with exponential backoff — `pkg/aiusechat/httpretry/` wraps `httpClient.Do` for all 4 backends (anthropic, openai responses, gemini, openaichat). Retries 429/5xx and transport errors with equal-jitter backoff, honors `Retry-After`, capped at MaxBackoff. 18 unit tests. +- [x] Step budget enforcement — `MaxSteps` on WaveChatOpts, default 50 for agent. Hard stop at limit, soft system-prompt warning at 80%. 3 tests. +- [x] Context compaction — `ContextBudget` on WaveChatOpts, default 100k tokens. When last step's `input_tokens` exceeds 80% of budget, drops middle messages from chatstore (keeps first + last 10). `CompactMessages()` on ChatStore. 4 tests. +- [x] Dangerous command detection — 12 regex patterns in `IsDangerousCommand()` covering rm -rf, force push, hard reset, pipe-to-shell, dd, mkfs, chmod 777, etc. Wired into shell_exec via `ToolVerifyInput` — forces approval even when mode auto-approves. 43 test cases. +- [x] Structured audit log — `ToolAuditEvent` type on `AIMetrics.AuditLog`. Each tool call emits timestamp, chatId, tool name, callId, truncated input, approval, duration, outcome, error. 1 test. + +## Optimization (Tier 2 — production-quality UX) + +- [x] Anthropic prompt caching — `cache_control: {type: "ephemeral"}` on last system prompt block + last tool definition. Uses `anthropicCachedToolDef` wrapper. Cache usage already tracked from responses. +- [x] Parallel tool execution — `Parallel` field on ToolDefinition. When all tool calls in a step are Parallel=true and none need approval, runs them concurrently via sync.WaitGroup. Marked: read_text_file, read_dir, get_scrollback, cmd_history. +- [x] Golden transcripts expanded to 21 — 18 new tests covering read/write/plan modes, error cases, multi-turn, cross-tool flows. +- [x] Live token counter — Backend sends `data-usage` SSE event after each step. Frontend `TermAgentTokenCounter` renders formatted total. +- [x] Runtime model switcher — `:model <name>` inline command. Frontend intercepts, stores override, resets chatId. Backend applies override to AIOptsType. +- [x] Diff preview for write_text_file + edit_text_file — Backend sends `originalcontent` + `modifiedcontent`. Frontend uses jsdiff `structuredPatch` for context-aware hunks with line-level coloring. Handles new file and no-changes cases. +- [x] Plan → Do handoff — "Execute Plan" button after write_plan completes. Stays in same session, switches to `:do` mode, sends minimal trigger. Backend reads plan file and injects into system prompt. + +## Warp-Style Inline Agent Blocks ✅ + +Replaced the floating overlay with inline agent blocks in the termblocks timeline: +- `TimelineEntry` union type: `cmd | agent-user | agent-response` +- `timelineAtom` merges CmdBlocks + agent entries sorted by timestamp +- `TermAgentChatProvider` hosts `useChat` invisibly, syncs messages into timeline +- `:` prefix in main input activates agent mode (accent color + mode badge) +- `InlineAgentUserMsg` + `InlineAgentResponse` render inline in the block stream +- Auto-scroll follows agent streaming +- Overlay removed entirely + +## Architecture + +- **`pkg/agent` = policy, `pkg/aiusechat` = mechanism.** One-way dependency. +- **Tool adapters** wrap `aiusechat.GetXxxToolDefinition()` + inject mode-aware approval closures. +- **`shell_exec`** creates user-visible cmd-blocks — the Crest differentiator. +- **`TermAgentModel` interface** — decouples chat provider from any specific ViewModel. +- **Settings fallback** — agent tries waveai mode system first, then reads `settings.json` directly. +- **API keys via secretstore** — stored in OS keychain, referenced by `ai:apitokensecretname`. +- **ForgeCode attribution**: Apache 2.0 preserved in `NOTICE` files + `UPSTREAM.md`. +- **MCP client** — `pkg/agent/mcp/` manages external MCP server connections. Config via `ai:mcpservers` in `settings.json`. Tools namespaced as `mcp__<server>__<tool>`, always require approval. +- **Skills** — `pkg/agent/skills.go` scans `.kilocode/skills/` at runtime, injects skill names + descriptions into the system prompt so the agent knows which guides are available. +- **Module path** — `github.com/s-zx/crest` (renamed from `wavetermdev/waveterm`). +- **Browser tools** — 4 tools (`browser.navigate/read_text/click/screenshot`) use Electron's webContents via RPC. Navigate updates block meta; read/click/screenshot route through `emain-wsh.ts` Electron handlers to the `<webview>` webContents. +- **Eval harness** — `pkg/agent/eval/` provides golden transcript replay (mock LLM, real tools, JSON test cases). `eval/harbor/` provides a terminal-bench 2.0 adapter for running Crest on the Harbor benchmark framework. +- **HTTP retry** — `pkg/aiusechat/httpretry/` is the shared retry wrapper used by every backend before the SSE stream starts. Retry happens at the HTTP layer only; once headers come back with a non-retryable status (typically 200 + `text/event-stream`), the caller owns the stream and any mid-stream error is surfaced to the user without retry (re-emitting partial deltas would corrupt the UI). diff --git a/docs/overnight-status-2026-05-20.md b/docs/overnight-status-2026-05-20.md new file mode 100644 index 000000000..c018ef0e8 --- /dev/null +++ b/docs/overnight-status-2026-05-20.md @@ -0,0 +1,202 @@ +# Overnight status — 2026-05-20 + +Run while you slept. Six tasks executed; one blocked on a pre-existing +in-flight refactor I deliberately didn't touch. + +## ✅ Done + +### N1 — `NOTICES.md` (MIT compliance) + +Created `NOTICES.md` at repo root. Includes: +- Warp's MIT copyright notice + license text (required by the MIT + license for substantial-portion ports) +- Inventory of 13 crest files derived from Warp source, with the + specific Warp `path:line` references each one ports from +- Audit-status reference pointing at + `docs/warp-agent-improvement-plan.md` → "Audit findings" + +### N2 — `task generate` + +Ran clean. **"no changes"** to every generated file: +`gotypes.d.ts`, `services.ts`, `waveevent.d.ts`, `wshclientapi.ts`, +`pkg/wshrpc/wshclient/wshclient.go`, `pkg/waveobj/metaconsts.go`, +`pkg/wconfig/metaconsts.go`. The A-class type changes (AskUserQuestion +nested variant, Citation kinds) don't surface in any of the +auto-generated wire types — they live in `uctypes` which isn't part of +the generator's scan list, and the wshrpc-side change (`AskAnswers` +field on `CommandWaveAIToolApproveData`) was already regenerated when +you ran `task generate` previously. + +### N5 — `docs/agent-architecture.md` §20–§23 + +Added four sections matching the existing five-paragraph format +(Problem / Approach / Files / Data flow / Trade-offs): + +- **§20** Track C — agent UI on the new term engine (P0) +- **§21** Typed citations + chip rendering (P1) +- **§22** `ask_user_question` tool (P2) +- **§23** Long-running command tools — read / write / transfer (P4) + +Each section cites the specific warp source paths it derives from and +records the trade-offs (TailBytes as crest extension, Esc vs Ctrl-C +choice, signal-sending dropped, etc.). Doc went from 499 lines → 596 +lines. + +## âŗ Still running when I had to stop + +### N3 — `npm install` ✅ recovered (post-wake fix) + +**Morning update**: clean recovery done while you were running `/usage`. Sequence that worked: + +1. `npm ci` failed with `ENOTEMPTY` on a stale staging dir (`node_modules/.katex-FsgGzoD2/dist/fonts`) — leftover from the interrupted overnight installs. +2. `rm -rf node_modules && WAVETERM_SKIP_APP_DEPS=1 npm install` — **clean**. 17 npm-audit warnings (8 moderate, 9 high) but install completed; `node_modules/.bin/vitest` symlink in place. +3. `npx vitest run frontend/app/term/engine/ frontend/app/term/terminal-model.test.ts` — **23/23 pass** in 8.53s. + +The original blocker was `postinstall.cjs → electron-builder install-app-deps`. Skipping it via the env var works fine for non-Electron dev (unit tests, dev server). You'll only need to drop the env var when you want to package the Electron desktop app. + +**Below is the overnight log — kept for context.** + +### N3 — overnight attempts âš ī¸ partial + +**Finished while you slept; left in a half-state. Recommend a clean +`rm -rf node_modules && npm install` first thing.** + +Sequence of what I tried: + +1. **First `npm install`**: exited cleanly per the task notification + (exit code 0) but the tail of its log shows `postinstall.cjs` + failed running `electron-builder install-app-deps`. Side effect: + `node_modules/vitest/package.json` was finally readable (so the + APFS read-empty issue is gone), but `node_modules/.bin/vitest` + symlink was missing — only 6 binaries total ended up linked. +2. **Retry with `WAVETERM_SKIP_APP_DEPS=1 npm install`**: still failed, + different error this time — module load explosion inside + `@electron/get/node_modules/fs-extra/lib/index.js:6`. Looks like + the skip env-var prevents the postinstall hook but a transitive + dep still has a broken state. +3. **Retry with `npm install --ignore-scripts`**: succeeded; vitest + binary symlink now exists. **BUT** the install graph is inconsistent + because the script phase was skipped — see N4 below. + +### N4 — Run all tests (FE side) âš ī¸ blocked by partial install + +`npx vitest run frontend/app/term/engine/ frontend/app/term/terminal-model.test.ts` +fails to load the vitest config: + +``` +TypeError: F.ResolverFactory.createResolver is not a function + at @tailwindcss/node/dist/index.mjs:10:4609 + at loadConfigFromBundledFile +``` + +`@tailwindcss/node` calls a method that doesn't exist in whatever +version of its transitive deps (`oxc-resolver`?) ended up installed +under `--ignore-scripts`. Dep version skew from a never-completed full +install. + +The clean fix is to make `npm install` succeed cleanly. The root +issue is **`postinstall.cjs` → `electron-builder install-app-deps` +failing**. Two paths to look at when you wake up: + +- The original error from path 1 is in + `/Users/mac/.npm/_logs/2026-05-19T22_33_01_220Z-debug-0.log`. Worth + scanning to see what electron-builder couldn't resolve. +- Or just try `WAVETERM_SKIP_APP_DEPS=1 npm install` once `node_modules` + is fully gone (`rm -rf node_modules package-lock.json` for the + nuclear option, but lock file is usually fine). + +### What this means for verification + +- `node_modules/vitest/package.json` is **readable**, so the original + APFS issue is solved. Whatever filesystem state was wrong has been + refreshed. +- The current `node_modules` graph is **functionally broken** for + test runs. A clean install should fix that. +- My audit code itself is verified through `pkg/aiusechat/uctypes/` + tests (Go side, separate from the wconfig blockage and the + node_modules blockage). + +## ❌ Blocked — not my fault, also not safe for me to fix + +### N4 — Run all tests ✅ (re-verified post-wake) + +**Morning re-run after node_modules clean + already-regenerated wshclient.go:** + +``` +ok github.com/s-zx/crest/pkg/agent/tools 0.482s +ok github.com/s-zx/crest/pkg/aiusechat 0.801s +ok github.com/s-zx/crest/pkg/aiusechat/uctypes 1.075s +``` + +Plus FE: **23/23 vitest pass**. + +The overnight wconfig-blocker diagnosis below was based on stale state — `task generate` had already updated `wshclient.go` to use the new `GetAIUserConfigCommand` / `WriteAIUserConfigCommand` shape, and the user's refactor (move AI provider/model/credentials from `wconfig.AIModeConfigType` → `uctypes.AIUserConfig`) is coherent end-to-end across `pkg/wconfig`, `pkg/wshrpc/wshrpctypes.go`, `pkg/wshrpc/wshserver/wshserver.go`, `pkg/aiusechat/userconfig.go`, and the regenerated `wshclient.go`. No fix needed. + +**Kept below for the diagnostic record:** + +### N4 — Run all tests (Go side) — overnight (stale-state diagnosis) + +`go test ./pkg/agent/tools/ ./pkg/aiusechat/` failed at the time with: +``` +pkg/wshrpc/wshclient/wshclient.go:531:83: undefined: wconfig.AIModeConfigUpdate +pkg/wshrpc/wshclient/wshclient.go:532:48: undefined: wconfig.AIModeConfigUpdate +FAIL github.com/s-zx/crest/pkg/agent/tools [build failed] +FAIL github.com/s-zx/crest/pkg/aiusechat [build failed] +``` + +This is **pre-existing**, not caused by my audit work: + +- `git status` shows `M pkg/wconfig/metaconsts.go`, `M settingsconfig.go`, `D pkg/wconfig/defaultconfig/waveai.json`, `M pkg/wshrpc/wshrpctypes.go`, `M pkg/wshrpc/wshserver/wshserver.go`, `M pkg/wshrpc/wshclient/wshclient.go` — all sat-modified at session start +- `grep "AIModeConfig"` returns no hits across `pkg/wconfig/` or `pkg/wshrpc/wshrpctypes.go` — the type has been removed from the source +- The auto-generated `wshclient.go` still references it; `task generate` says "no changes" because it considers the file in sync +- Conclusion: you're mid-refactor on the AI mode config system (the `D waveai.json` is the giveaway). Either the type is renamed and the generator scan needs widening, or the type is supposed to be re-added under a new name. + +**I left it alone.** Touching `wshclient.go` (generated) gets erased on +the next regen; touching `wshrpctypes.go` (your in-flight edits) +conflicts with your work; adding a placeholder type to `wconfig` +without knowing your intended shape could deviate. + +### What this means for verification + +- **Audit A-class work itself is verified**: `go test ./pkg/aiusechat/uctypes/` passes — that's where the Citation + AskUserQuestion type changes live and they round-trip cleanly through JSON tests. +- **Audit tool changes (long_running_read/write, ask_user_question)** can't be exercised through `go test` until you resolve the AIModeConfig piece. Code is structurally verified by inspection (matches warp source per file:line in the audit doc); behavioral verification waits. +- **FE side** depends on N3 finishing. + +## 📋 What's still on the backlog (not started overnight) + +From the "čŋ˜æœ‰äģ€äšˆæ˛Ąåš" inventory: + +**Should-do** (no blocker, just didn't get to): +- File-citation jump implementation — `onAgentFileJump` is still a + clipboard stub at `terminal-view.tsx:303-311`. Needs a filename→block + index or `getApi().openExternal("file://...")` path. +- `tool-ask-card` unavailable / finished render branches — only + active + completed implemented. + +**Explicit defers** (per audit doc): +- P3 Markdown delta — needs profile data from dev server +- `long_running_write.line` Windows CR vs LF +- `long_running_write.block` bracketed-paste enablement gate + +## Recommended morning sequence + +1. Fix the wconfig piece — probably 5 min if you have the intended + shape of `AIModeConfigUpdate` in mind, or check git stash / a + parallel branch. +2. Confirm `go test ./pkg/agent/tools/ ./pkg/aiusechat/` passes after + that — should be the case based on my isolated uctypes verification. +3. Check on `npm install` — if completed, run `npx vitest run` for the + engine tests + `task electron:quickdev` for end-to-end smoke. +4. End-to-end smoke per `docs/warp-agent-improvement-plan.md` P0.7 + checklist. + +## File map of what I touched overnight + +``` ++ NOTICES.md (new) ++ docs/overnight-status-2026-05-20.md (this file) +M docs/agent-architecture.md (+97 lines: §20-§23) +``` + +Plus the audit-day files from the earlier session (already documented +in `warp-agent-improvement-plan.md`). diff --git a/docs/permissions-v2-design.md b/docs/permissions-v2-design.md new file mode 100644 index 000000000..4057765aa --- /dev/null +++ b/docs/permissions-v2-design.md @@ -0,0 +1,615 @@ +# Permissions v2 — Design Doc + +**Status:** revised draft ¡ **Owner:** native-agent worktree ¡ **Date:** 2026-04-27 (revised) +**Tracker:** [`claude-code-parity.md`](./claude-code-parity.md) §3 +**Reference designs:** +- Claude Code (`/Users/user/Documents/Claude-Code/`) — rule grammar, decision pipeline, bypass-immune safety +- pi-mono (`badlogic/pi-mono`) — minimalism: drop Mode, drop prompt templates, derive everything from rules + posture + +> **What changed from earlier drafts:** the **Mode** axis (`ask` / `plan` / `do`) is gone. After deciding the prior design's Mode×Posture×Rules×Safety was 4-dimensional and confusing for a simplification effort, we collapsed Mode into rules + system-prompt customization. There is now exactly **one work axis (rules) and one strictness axis (posture)**. `bench` survives as an API-only hidden escape for eval harnesses. No prompt-template / `/plan` / `/ask` slash commands — see §10 for what replaces them. + +--- + +## 1. Why v1 isn't enough + +```go +// pkg/agent/modes.go (existing) +type ApprovalPolicy struct { + AutoApproveAll bool + AutoApproveTools map[string]bool + RequireApproval map[string]bool +} +``` + +Every tool call → `mode.ResolveApproval(toolName)` → `auto-approved` or `needs-approval`. One-shot per call. Breaks down for: + +- **Real coding work** — every `edit_text_file` needs a click. Users burn out and switch to `bench` (`AutoApproveAll`), losing all safety. +- **Repeated commands** — running `npm install` 8 times in a debugging session = 8 prompts. +- **Path-aware safety** — currently can't say "auto-allow writes inside cwd, prompt for everything else." +- **No memory** — chat ends, every preference learned during it is gone. + +Plus the v1 system mixes 5 concepts that should be separate (mode → tool list → approval policy → state machine → UI). Adding granularity on top of that mix doesn't help; the mix itself has to go. + +--- + +## 2. Architecture: three layers, two user-facing axes + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Tool allowlist (process startup, immutable for the run) │ +│ --tools / --no-builtin-tools / --no-tools │ +│ Bound at agent launch, defines the universe of available │ +│ tools. The permission engine never sees calls to tools │ +│ excluded here. │ +└──────────────────────────────────────────────────────────────┘ + │ + â–ŧ +┌──────────────────────────────────────────────────────────────┐ +│ System prompt (single source, project + global override) │ +│ pkg/agent/prompts/*.md → ~/.crest/SYSTEM.md │ +│ → .crest/SYSTEM.md (project) → APPEND_SYSTEM.md │ +│ Tone, project conventions, custom guidance live here. │ +│ Permissions don't gate prompt text — that's the user's job. │ +└──────────────────────────────────────────────────────────────┘ + │ + â–ŧ +┌──────────────────────────────────────────────────────────────┐ +│ Permission Engine — every tool call passes through │ +│ │ +│ ┌────────────────────────────────────────────────────┐ │ +│ │ Layer 1 — Bypass-immune Safety │ │ +│ │ Hard list (`.git/`, `.ssh/`, `.env*`, `rm -rf /`, │ │ +│ │ `curl|sh`, `prefix:sudo`, force-push to mainâ€Ļ). │ │ +│ │ Always prompts; no posture can disable it. │ │ +│ └────────────────────────────────────────────────────┘ │ +│ │ │ +│ â–ŧ │ +│ ┌────────────────────────────────────────────────────┐ │ +│ │ Layer 2 — Rules (allow / deny / ask) │ │ +│ │ User-defined. Tool-name + content matcher. │ │ +│ │ Loaded from 5 scopes; deny-anywhere wins. │ │ +│ └────────────────────────────────────────────────────┘ │ +│ │ │ +│ â–ŧ │ +│ ┌────────────────────────────────────────────────────┐ │ +│ │ Layer 3 — Posture default │ │ +│ │ default → ask ¡ acceptEdits → auto-allow edits │ │ +│ │ ¡ bypass → auto-allow all (still constrained by │ │ +│ │ Layer 1) ¡ bench → auto-allow ignoring Layer 1 │ │ +│ └────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ +``` + +**User-facing axes:** + +1. **Rules** — `/permissions` opens the editor; approval prompts can persist new rules. +2. **Posture** — `Shift+Tab` cycles `default → acceptEdits → bypass`; status pill shows current posture. + +**Hidden:** `bench` posture — API-only, activated by Harbor/eval harnesses posting `mode: "bench"`. Users never select it. + +--- + +## 3. Rules + +### 3.1 Grammar + +```go +// pkg/agent/permissions/rule.go +type Rule struct { + Behavior RuleBehavior // "allow" | "deny" | "ask" + ToolName string // "shell_exec", "edit_text_file", "*" + Content string // optional; tool-specific matcher syntax (see below) + Source RuleSource // who set it (precedence) + AddedAt int64 // unix ms; for UI only +} + +type RuleBehavior string +const ( + RuleAllow RuleBehavior = "allow" + RuleDeny RuleBehavior = "deny" + RuleAsk RuleBehavior = "ask" +) +``` + +**Tool-name format** — exact tool name or `*`. MCP tools use the existing `mcp__server__tool` convention; `mcp__server__*` matches all tools from one server. + +**Content matcher** — interpreted by each tool's `MatchContent` method: + +| Tool | Matcher syntax | Examples | +|---|---|---| +| `shell_exec` | exact, or `prefix:<cmd>` | `npm install`, `prefix:git`, `prefix:cargo build` | +| `edit_text_file`, `write_text_file`, `multi_edit` | gitignore-style glob over absolute path | `/Users/me/repo/**`, `**/*.go`, `!**/secrets/**` | +| `read_text_file`, `read_dir` | gitignore-style glob | same | +| `web_fetch` | URL host/path glob | `https://api.github.com/**`, `https://*.internal/**` | +| `browser.navigate` | URL host glob | `https://github.com/*` | +| `spawn_task` | (no content matcher today) | n/a | +| any other | exact-match content | (rarely needed) | + +Empty `Content` matches *any* call to that tool. + +### 3.2 Wire format + +Compact, human-friendly. Lives in settings.json under `ai:permissions`: + +```json +{ + "ai:permissions": { + "allow": [ + "shell_exec(prefix:npm)", + "shell_exec(prefix:git status)", + "edit_text_file(/Users/me/work/**)", + "read_text_file(*)", + "mcp__filesystem__*" + ], + "deny": [ + "shell_exec(rm -rf *)", + "shell_exec(prefix:sudo)", + "edit_text_file(**/.env)", + "edit_text_file(**/credentials*)" + ], + "ask": [ + "shell_exec(prefix:npm publish)", + "shell_exec(prefix:git push --force)" + ], + "defaultPosture": "acceptEdits" + } +} +``` + +`ParseRule(s string) (Rule, error)` splits on the first `(`, escapes `\)` and `\\`. Rejects malformed strings at load time so typos don't silently neuter a rule. + +--- + +## 4. Scopes + +Five scopes, strict precedence (higher beats lower): + +| # | Scope | Where it lives | Who writes | +|---|---|---|---| +| 5 | `cliArg` | runtime, never persisted | `--allow-tool`, `--deny-tool` flags (future) | +| 4 | `user` | `~/.config/waveterm/settings.json` `ai:permissions` | Settings UI / hand-edit | +| 3 | `sharedProject` | `<cwd>/.crest/permissions.json` | committed to repo | +| 2 | `localProject` | `<cwd>/.crest/permissions.local.json` | gitignored (per-machine personal) | +| 1 | `session` | in-memory, lifetime of the agent chat | Approval prompt | + +**Resolution rule:** when multiple scopes have rules for the same tool: +1. **Deny in any scope wins.** No way to opt out of a hard deny. +2. Then highest-precedence `ask`. +3. Then highest-precedence `allow`. + +Within a scope, more-specific content patterns beat broader ones (`shell_exec(prefix:git push)` ask beats `shell_exec(prefix:git)` allow). + +**Why this layout:** +- `user` = personal preferences across all projects (`prefix:git status`, etc.) +- `sharedProject` = team conventions, committed (e.g. "always allow `npm test`, never allow `npm publish`") +- `localProject` = my-machine personal overrides per repo +- `session` = "remember for this chat only" +- No `policySettings` enterprise tier — Crest isn't enterprise-managed today. + +--- + +## 5. Posture (the strictness axis) + +**Three user-facing values + one hidden:** + +| Posture | Behavior on calls the rules don't match | Bypass-immune safety | New-chat default? | Audience | +|---|---|---|---|---| +| `default` | Falls back to per-tool `DefaultBehavior()` (mutations → ask, reads → allow) | fires | no | cautious users / unfamiliar repo | +| `acceptEdits` | Auto-allow file-edit tools (`edit_text_file`, `write_text_file`, `multi_edit`) when target path is inside `cwd`. Other tools fall through to `default`. | **fires** (can't auto-allow `.env`/`.git/`/`.ssh/` etc.) | **yes** | iterative coding (the bundled default) | +| `bypass` | Auto-allow everything | **fires** | no | "trust me" / inside a sandbox | +| `bench` | Auto-allow everything | **off** | no — eval-only, API-activated | non-interactive eval (Harbor/TB2) | + +**Why `acceptEdits` is the bundled default** — diverging from Claude Code's `default` default: + +- Clicking through every `edit_text_file` during interactive coding is the #1 friction in current usage. +- Risk is bounded: file edits get a `filebackup.MakeFileBackup` snapshot before write; the mtime tracker (commit `0ce9f60b`) refuses edits to files that changed externally; bypass-immune paths still prompt; `shell_exec` still prompts; deny rules still fire. +- `default` posture is one `Shift+Tab` away when the user wants strict control. +- Claude Code's audience includes high-stakes shared environments. Crest is a personal terminal for local coding — `acceptEdits` matches that. + +**Posture state lives per-chat** in the session. New chats start in the user's `defaultPosture` setting (defaults to `acceptEdits`). User flips per-chat with `Shift+Tab` (cycles `default → acceptEdits → bypass → default`) or `/permissions` to open the chooser. + +**`bench` posture** is privileged — only set when the API receives `mode: "bench"` (Harbor's existing convention). Never user-selectable. Skips bypass-immune safety entirely so eval harnesses get clean signal. This is the only place `mode` still appears in the system, and only as an API alias for "set posture to bench." + +--- + +## 6. Bypass-immune safety list + +The `acceptEdits` and `bypass` postures auto-approve calls the rules don't match — but a fixed safety list overrides that and forces a prompt regardless. `bench` posture skips this list entirely. + +- **`shell_exec`:** + - `rm -rf /`, `rm -rf ~`, `rm -rf $HOME` + - `git push --force` to `main`/`master` + - anything containing `curl | sh` / `wget | sh` + - fork-bomb patterns (`:(){:|:&};:` and obvious siblings) + - `prefix:sudo` +- **File tools** (`edit_text_file`, `write_text_file`, `multi_edit`): + - writes to `.git/`, `.crest/`, `.ssh/`, `.aws/`, `.gnupg/` + - OS shell configs (`.bashrc`, `.zshrc`, `.profile`, `.bash_profile`) + - `.env*` + - files containing `credentials` or `secret` in the name +- **`web_fetch` / `browser.navigate`:** deferred — `localhost` / `127.0.0.1` on common dev ports is a footgun for legitimate local-server debugging. Add when we see an incident. + +Safety checks emit an `ask` decision with reason `"safetyCheck"`. The prompt UI explains why the looser posture was overridden so the user isn't surprised by a sudden approval prompt mid-stream. + +--- + +## 7. Decision pipeline + +``` +Decide(req {ToolName, Input, ChatId, Cwd, Posture}): + + 1. Posture == bench? + → Decision{Allow, reason=posture-bench, bypassImmune=false} + (eval-only escape; skips safety entirely) + + 2. Load rules from all scopes for (chat=ChatId, cwd=Cwd) + → flat []Rule sorted by (scope precedence desc, content specificity desc) + + 3. Tool-level deny rule (Content == "")? + → Decision{Deny, reason=rule} + + 4. Content-specific Deny or Ask rule match? + - run tool.MatchContent(input, rule) for each rule with non-empty + Content where Behavior is Deny or Ask + - first match (highest precedence) wins + - Allow rules are intentionally NOT matched here — they wait + until step 6 so safety can run first + → Decision{rule.Behavior, reason=rule} + + 5. Per-tool safety check (bypass-immune)? + - file tools: target path matches bypass-immune list → ask{safetyCheck, bypassImmune=true} + - shell_exec: command matches bypass-immune list → same + - default: passthrough + → if non-passthrough, return that decision (ignores posture in step 7) + + 6. Allow rule match? + a. Tool-level allow (Content == "") → Decision{Allow, reason=rule} + b. Content-specific allow → Decision{Allow, reason=rule} + + 7. Posture-driven default for unmatched calls: + a. posture == bypass → Decision{Allow, reason=posture-bypass} + b. posture == acceptEdits AND tool is a file-edit AND target inside cwd + → Decision{Allow, reason=posture-acceptEdits} + c. posture == default → fall through + + 8. Per-tool default behavior: + - file-edit tools default to ask + - shell_exec defaults to ask + - read tools default to allow + - web_fetch / browser defaults to ask + → Decision{tool.DefaultBehavior(), reason=default} + + 9. Behavior == Ask? + - tool.SuggestRules(input) populates Decision.Suggestions + - return for UI to prompt user + + 10. Behavior == Allow/Deny? return immediately, no prompt. +``` + +**Step 1** is the only Mode-derived special case left, and it's renamed to `posture == bench` to match the new vocabulary. + +**Step 4 splits Allow out for safety reasons.** Letting a content-specific Allow short-circuit before step 5 would let a rule like `shell_exec(prefix:echo)` auto-approve `echo \`rm -rf /\`` — the safety substring matcher never gets a chance to evaluate the inner command. Splitting Deny/Ask (which short-circuit before safety, the conservative direction) from Allow (which sits below safety) preserves the intent without ordering surprises. + +**Step 5** runs *before* every Allow path (step 6 tool-level + content-specific, step 7 posture auto-allow) so safety wins over `acceptEdits` and `bypass` postures and over user-supplied Allow rules alike. + +**Step 6/7 ordering:** explicit Allow rules win over posture defaults so a user-added `shell_exec(prefix:npm)` allow rule beats whatever the posture would do. + +--- + +## 8. Architecture (code) + +### 8.1 Package layout + +``` +pkg/agent/permissions/ (new package) +├── permissions.go // Engine, Decide, types +├── rule.go // Rule, ParseRule, Match +├── matcher_glob.go // gitignore-style globs (use github.com/sabhiram/go-gitignore) +├── matcher_prefix.go // prefix:<...> shell-command matching +├── safety.go // SafetyCheck, BypassImmuneList +├── store.go // RuleStore: load/save/scope precedence +├── store_session.go // In-memory session scope +├── store_settings.go // wconfig-backed user/project scopes +└── permissions_test.go +``` + +Top-level package because the engine is reusable beyond the agent (future REPL, future MCP-only flows). Tools call into it via a small interface to avoid a package cycle. + +### 8.2 Core types + +```go +// pkg/agent/permissions/permissions.go + +type Decision struct { + Behavior RuleBehavior // "allow" | "deny" | "ask" + Reason DecisionReason + Rule *Rule // populated when Behavior decided by a rule + Suggestions []Rule // populated only when Behavior == Ask + UpdatedInput map[string]any // tool-modified input (rare) +} + +type DecisionReason struct { + Kind string // "rule" | "tool" | "safetyCheck" | "posture" | "default" + Detail string + BypassImmune bool // true for safety checks; false otherwise +} + +type CheckRequest struct { + ToolName string + Input map[string]any + ChatId string // for session-scope rules + Cwd string // for project-scope rule loading + Posture string // "default" | "acceptEdits" | "bypass" | "bench" +} + +type Engine interface { + Decide(ctx context.Context, req CheckRequest) Decision + PersistRules(ctx context.Context, scope RuleScope, rules []Rule) error + LoadRulesForChat(ctx context.Context, chatId, cwd string) ([]Rule, error) +} +``` + +Note: no `Mode` field on `CheckRequest`. The only place mode survives is the API-side aliasing of `mode: "bench"` to `posture: "bench"`. + +### 8.3 Tool integration — `PermissionAdapter` + +```go +// pkg/aiusechat/uctypes/uctypes.go (extension to ToolDefinition) +type ToolDefinition struct { + // ... existing fields + Permissions PermissionAdapter // optional; nil for tools w/o per-call logic +} + +type PermissionAdapter interface { + MatchContent(input map[string]any, pattern string) bool + CheckSafety(input map[string]any) SafetyResult // bypass-immune check + SuggestRules(input map[string]any) []SuggestedRule + DefaultBehavior() string // "allow" | "ask" | "deny" + IsFileEdit() bool // for posture acceptEdits handling + TargetPath(input map[string]any) string // for path-relative-to-cwd checks +} + +type SafetyResult struct { + Triggered bool + Reason string // human-readable, shown in the prompt +} +``` + +Why duplicate the interface in `uctypes`? The pure permissions package can't import `pkg/agent/tools` without a cycle. `uctypes` defines the contract; `pkg/agent/permissions` adapts it. + +Existing tool factories in `pkg/agent/tools/*.go` add a `PermissionAdapter` — most are 20-line implementations. + +### 8.4 Replacing the existing approval flow + +Today's flow: +``` +processToolCallInternal (usechat.go) + → toolCall.ToolUseData.Approval already set by registry.go (mode.ResolveApproval) + → if NeedsApproval: WaitForToolApproval +``` + +New flow (using the BeforeToolHook pipeline shipped in commit "D"): +``` +processToolCallInternal + → engine.Decide(req) → Decision + → switch Decision.Behavior: + - Allow: set Approval=AutoApproved, run + - Deny: set status=Error("denied: " + reason), skip run + - Ask: set Approval=NeedsApproval, attach Decision.Suggestions + for the FE; WaitForToolApproval; on user-approve, persist + any selected suggestions via engine.PersistRules +``` + +**`engine.Decide` is registered as a global `BeforeToolHook`** on `WaveChatOpts.BeforeToolHooks` at agent setup. The hook returns `nil` for Allow (proceed), an error `*AIToolResult` for Deny, or signals "need approval" via the existing `RegisterToolApproval` mechanism. + +`mode.ResolveApproval` in `pkg/agent/registry.go` is **gone**. The whole `pkg/agent/modes.go` file goes away — replaced by: +- `pkg/agent/permissions` (new) +- A small system-prompt seeder in `pkg/agent/prompts.go` that picks the right base prompt (no longer keyed by mode name; just one default + user overrides via SYSTEM.md) + +### 8.5 Settings schema + +```go +// pkg/wconfig/types.go (extension) +type SettingsType struct { + // ... existing + AIPermissions *AIPermissionsConfig `json:"ai:permissions,omitempty"` +} + +type AIPermissionsConfig struct { + Allow []string `json:"allow,omitempty"` // "shell_exec(prefix:npm)" + Deny []string `json:"deny,omitempty"` + Ask []string `json:"ask,omitempty"` + DefaultPosture string `json:"defaultPosture,omitempty"` // "default"|"acceptEdits"|"bypass". Defaults to "acceptEdits". +} +``` + +Project-shared rules at `<cwd>/.crest/permissions.json`; per-user-per-project at `<cwd>/.crest/permissions.local.json`. Same JSON shape. + +### 8.6 Frontend + +- **Status pill** in agent overlay header: `permissions: acceptEdits` (clickable to open `/permissions`). Color-coded: neutral `default`, info `acceptEdits`, warning `bypass`. +- **`Shift+Tab`** keybinding cycles posture (`default → acceptEdits → bypass → default`) when overlay has focus. +- **`/permissions`** opens chooser with the three postures + brief explanation of each, plus a link to the rules editor and "set as default for new chats" checkbox. +- **Approval prompt** extends `term-agent.tsx` `TermAgentApprovalButtons` with suggestion list and destination picker: + +``` +┌────────────────────────────────────────────────────────────┐ +│ shell_exec wants to run: │ +│ npm install --save-dev typescript │ +│ │ +│ [ Approve ] [ Deny ] │ +│ │ +│ Or remember: │ +│ ◯ This command exactly │ +│ ◉ All `npm` commands (prefix:npm) │ +│ ◯ All shell_exec calls (entire tool) │ +│ │ +│ Save to: ◉ Session ◯ This project ◯ Always │ +│ │ +│ [ Approve and Remember ] │ +└────────────────────────────────────────────────────────────┘ +``` + +Suggestions come from `tool.SuggestRules(input)`. `wshrpc agent:tool-approve` payload extends with `acceptedSuggestions: Rule[]` and `destination: RuleScope`. + +--- + +## 9. Default rules shipped (in-binary) + +``` +allow: + - read_text_file(*) + - read_dir(*) + - search(*) + - get_scrollback(*) + - cmd_history(*) + - todo_read(*) + - todo_write(*) + - shell_exec(prefix:git status) + - shell_exec(prefix:git diff) + - shell_exec(prefix:git log) + - shell_exec(prefix:ls) + - shell_exec(prefix:pwd) + # NOT included: prefix:cat, prefix:echo. cat would auto-approve + # `cat ~/.ssh/id_rsa` / `cat .env`; echo + shell substitution + # (`echo $(rm -rf /)`) sneaks past the safety substring matcher. + # `read_text_file` covers safe file reads with proper hooks. + +deny: + - shell_exec(prefix:sudo) + - shell_exec(prefix:rm -rf /) + - shell_exec(prefix:rm -rf ~) + - shell_exec(prefix:rm -rf $HOME) + - shell_exec(prefix:curl | sh) + - shell_exec(prefix:wget | sh) + - edit_text_file(**/.env) + - edit_text_file(**/.env.*) + - edit_text_file(**/credentials*) + - edit_text_file(**/.ssh/**) + +ask: (nothing — handled by per-tool DefaultBehavior) +``` + +These ship as in-binary defaults at the lowest precedence, below `session`. The user can override any one. They only become "real" persisted rules when the user changes them via the UI. + +--- + +## 10. What replaces Mode + +The previous Mode axis bundled three things (tool subset, system prompt, default approval policy). After v2 they map roughly as follows: + +| Old: Mode value | New: how to get equivalent behavior | +|---|---| +| `ask` (read-only Q&A) | Launch with `--tools read,grep,find,ls`. **Or** add session deny rules: `deny edit_text_file(*)` etc. **Or** set posture `default` and let the agent get refused per-call. | +| `plan` (propose-first) | **Open question — deferred.** v2 does not ship a replacement for plan mode. The bundled system prompt does not change. Users who relied on plan mode will currently have no first-class equivalent. Whether to add one (slash command, system-prompt opt-in, dedicated posture, or something else) is a separate design conversation. | +| `do` (full agent) | Default. Posture `acceptEdits` (the bundled default). | +| `bench` (eval harness) | Stays. API-only. POST `mode: "bench"` to the agent endpoint → backend forces posture to `bench`. Hidden from user-facing UI. | + +**No `/plan` slash command, no prompt template system.** Pi's argument: every prompt-template system fails the 90/10 test (90% of users never use it; 10% have 5 own flows it doesn't cover). The combination of `@filename` references + `SYSTEM.md` covers the 10% case without the slash machinery. + +This explicitly leaves a gap where `plan` mode used to live. v2 permissions does not try to fill it. The shipped system prompt stays as-is; we are not adding "propose a plan first" guidance to SYSTEM.md as part of this work. If plan-mode loss turns out to be a real problem, the fix is a follow-up design — possibly a posture, possibly something else — informed by actual usage data, not preemptively baked into the default prompt. + +**Slash commands that survive** are control-flow only: +- `/permissions` — view rules, change posture +- `/model` — switch model +- `/clear` — clear chat +- `/compact` — manual compaction trigger +- `/login` / `/logout` — OAuth +- `/share` — export session + +These don't touch the next prompt's content. They invoke a function. Different category from `/plan` / `/test` / `/refactor` (which we're cutting). + +--- + +## 11. Migration plan + +### 11.1 Existing chats / settings + +The `Mode` concept disappears from the public API surface for new clients, but old clients posting `mode: "ask"` etc. need to keep working until FE is updated. Backend translates: + +| API receives | Engine treats as | +|---|---| +| `mode: "ask"` | (no mode); session-scope deny rules `edit_text_file(*)`, `write_text_file(*)`, `multi_edit(*)`, `shell_exec(*)`. Posture: `default`. | +| `mode: "plan"` | (no mode); same deny rules as `ask`; posture: `default`. The system prompt is not modified — the "propose-first" semantic that plan mode used to add is not preserved. See §10 (open question). | +| `mode: "do"` | (no mode); posture: `defaultPosture` setting. | +| `mode: "bench"` | posture: `bench`; bypass-immune safety off. | + +This keeps Harbor unchanged and gives a 1-release window for the FE to drop the mode picker. + +### 11.2 Files removed + +- `pkg/agent/modes.go` — gone. ApprovalPolicy struct, mode definitions, `ResolveApproval` method all deleted. +- `pkg/agent/registry.go` — `mode.ResolveApproval` calls deleted; tool registration no longer takes an approval-resolver closure (the engine decides at run time, not registration time). + +### 11.3 Files added + +- `pkg/agent/permissions/` package (per §8.1) +- `pkg/wconfig/types.go` — `AIPermissionsConfig` extension +- `frontend/.../PermissionsPanel.tsx` — `/permissions` UI + +### 11.4 Implementation order + +1. **Types + parser** — `pkg/agent/permissions/{rule,permissions}.go`. Rule, RuleBehavior, Posture, CheckRequest, Decision. ParseRule/Stringify. Unit tests for the grammar. +2. **Matchers** — glob (path), prefix (shell), exact. Unit tests. +3. **RuleStore** — load/save settings.json; precedence walk; project file loading. Unit tests. +4. **Safety list** — `safety.go` with the §6 patterns hard-coded. Unit tests covering each. +5. **Engine.Decide** — full pipeline (§7). Unit tests covering each posture × rule-set combination. +6. **Tool adapters** — implement `PermissionAdapter` for `shell_exec`, `edit_text_file`, `multi_edit`, `write_text_file`, `read_text_file`, `web_fetch`, `browser.*`, `spawn_task`. Each with `SuggestRules`. +7. **Wire into usechat.go** — register `engine.Decide` as a global `BeforeToolHook` on `WaveChatOpts.BeforeToolHooks` (the hook pipeline from D). Add `Posture` field to `Session` (per-chat state). API endpoint accepts `mode: "bench"` → forces posture to bench. Delete `pkg/agent/modes.go` and `mode.ResolveApproval` callers. +8. **Frontend prompt** — extend `TermAgentApprovalButtons` with suggestions list and destination picker. New wshrpc payload. +9. **Settings UI** — read-only rule list under a new "Permissions" tab. (Editor in a follow-up.) +10. **Default rules** — bundle the §9 list into the engine's `inBinaryRules` source, lowest precedence. +11. **Documentation** — update `claude-code-parity.md` §3 status, write a short user-facing guide on how rules work, document `--tools` + SYSTEM.md as the substitute for `ask` / `plan` modes. + +Estimated: ~3 sittings to step 7 (functional parity + mode removal); 1 more sitting for steps 8-11. + +--- + +## 12. Decisions log + +| Decision | Why | +|---|---| +| **Drop Mode entirely** | Old design bundled three independent things (tool subset, prompt, approval policy). Splitting them — rules, SYSTEM.md, posture — exposes one knob each. Resolved 2026-04-27. | +| **Keep `bench` as API-only escape** | Eval harnesses (Harbor, TB2) need bypass-immune safety off and don't have a user to prompt. `mode: "bench"` over the API maps to posture `bench`. Never user-selectable. | +| **No `/plan` / `/ask` / prompt-template slashes** | Per pi-mono. Replaced by `@filename` reference (per-call) and SYSTEM.md (persistent). Saves the surface area of a template registry, conflict resolution, etc. | +| **Keep utility slashes** (`/permissions`, `/model`, `/clear`, `/compact`) | These invoke functions, not template expansion. Different category. | +| **Bundled default posture is `acceptEdits`** | Diverges from Claude Code (`default` default). Matches Crest's audience (personal local coding). Risk bounded by file backups, mtime tracking, bypass-immune paths, deny rules, `shell_exec` still prompting. | +| **`Shift+Tab` cycles posture** | Matches Claude. Cycle: `default → acceptEdits → bypass → default`. | +| **Three user-facing posture values** | `default`, `acceptEdits`, `bypass`. (`bench` hidden.) Pi has these same three; we adopt for consistency with the wider ecosystem. | +| **Permissions package at `pkg/agent/permissions`** | Top-level keeps reuse open; adapter interface in uctypes avoids cycle. | +| **`PermissionAdapter` lives on `ToolDefinition`** | Per-tool content matching + suggestions belong with the tool definition; the engine treats them via interface. | +| **In-binary default rules shipped** | Covers the 80% case (`git status` etc. always allowed) without forcing every user to configure. Lowest precedence so they're easy to override. | +| **`localProject` is gitignored, `sharedProject` is committed** | Standard convention from `.env.local` etc. | +| **One unified rule pool (no per-mode/per-posture rule namespaces)** | Simpler mental model. Mode-specific rules confused users in the prior draft. | + +--- + +## 13. Out of scope + +- **Classifier-based auto-approve** (Claude's `auto`/YOLO + LLM transcript classifier). Defer indefinitely; rules + posture cover the same UX for far less complexity. +- **PermissionRequest webhook hooks**. Crest's `BeforeToolHook` pipeline already supports custom blocking — webhooks aren't needed. +- **Policy tier** (admin/enterprise managed settings). Skip until a user asks. +- **CLI rule-management commands** (`crest permissions add`). Settings JSON edit is enough for v1. +- **`additionalDirectories`** (Claude's per-extra-dir rules). Useful but adds surface area; defer. +- **Replacement for plan mode.** Removing Mode leaves a gap where `plan` lived; this design intentionally does not fill it. No system-prompt changes ship with v2. If plan-mode-style behavior turns out to matter, that's a separate design pass — likely informed by what users actually miss. + +--- + +## 14. References + +Claude Code source paths (reference only — do not import or copy verbatim): + +- `src/types/permissions.ts` — types, posture constants, rule shape +- `src/utils/permissions/permissions.ts` — `hasPermissionsToUseToolInner` (decision pipeline) +- `src/utils/permissions/PermissionMode.ts` — posture lifecycle (their "mode" is our "posture") +- `src/utils/permissions/getNextPermissionMode.ts` — Shift+Tab cycle +- `src/utils/permissions/permissionRuleParser.ts` — rule string parser +- `src/utils/permissions/applyPermissionUpdates.ts` — rule mutation +- `src/utils/permissions/persistPermissionUpdates.ts` — save to disk +- `src/components/permissions/PermissionPrompt.tsx` — approval UI +- `src/tools/BashTool/bashPermissions.ts` — Bash content matching +- `src/tools/FileEditTool/filesystem.ts` — file path matching + +pi-mono reference (for the simplification stance): + +- `packages/coding-agent/README.md` "Philosophy" section — no mode, no plan mode, no permission popups (we keep popups but adopt the rules + posture minimalism) diff --git a/docs/redesign-code-review-panel.md b/docs/redesign-code-review-panel.md new file mode 100644 index 000000000..6d2205a53 --- /dev/null +++ b/docs/redesign-code-review-panel.md @@ -0,0 +1,341 @@ +# Code Review Panel — Warp-inspired Redesign Spec + +**Target file:** `frontend/app/codereview/git-panel.tsx` +**Related:** `frontend/app/codereview/git-model.ts` (do NOT change — read-only reference) +**Framework:** React + Tailwind v4, Jotai atoms via `GitModel.getInstance()` +**Theme:** Dark-only (matches surrounding app). All tokens below should resolve via existing Tailwind theme variables (`text-primary`, `text-secondary`, `bg-accent`, etc.) — do NOT hardcode `#16161e` or raw hex in the component. + +--- + +## 1. Design direction + +Warp's interface treats each shell command as a **block** — a self-contained, rounded, hairline-bordered card with generous internal padding, where metadata sits in a compact header row and payload content flows below. The overall feel is: + +- **Refined dark**, not pitch black. Surfaces are layered (`bg`, `bg-elevated`, `bg-elevated+1`) with ~3% luminance steps between them. +- **One warm accent**, used sparingly — for the active branch, focused block border, and the primary CTA. Never for decoration. +- **Mono-first numerics**. All counts, SHAs, line numbers, and diff stats are tabular-lining mono. Labels are a geometric sans. +- **Hairlines over fills**. Dividers are 1px at ~8% opacity; rows never get heavy backgrounds except for diff add/remove which use a left-edge indicator bar + very faint tint (4–6% not 30%). +- **Quiet motion**. 120–180 ms ease-out transitions. Nothing bounces. Chevron rotates, block background lightens 2%, and the diff region height-animates on expand. + +Apply this to the code review panel by converting the current flat row list into a **stack of file blocks**, elevating the branch summary into a dedicated header card, and reworking the diff viewport to mirror Warp's command-output frame. + +--- + +## 2. Layout anatomy + +``` +┌───────────────────────────────────────────────┐ +│ [◧] Code review â¤ĸ ✕ │ <- Title bar (unchanged behavior) +├───────────────────────────────────────────────┤ +│ ╭───────────────────────────────────────────╮ │ +│ │ main 7 files │ │ <- Branch summary card +│ │ uncommitted +862 −102 │ │ +│ ╰───────────────────────────────────────────╯ │ +│ │ +│ ╭─ git-panel.tsx ───────────── M +126 −101 ╮│ <- File block (collapsed) +│ ╰────────────────────────────────────────────╯│ +│ │ +│ ╭─ modalregistry.tsx ───────── M +2 −0 ╮│ <- File block (expanded) +│ │ ┌───────────────────────────────────────┐ ││ +│ │ │ @@ -6,7 +6,7 @@ │ ││ +│ │ │ 6 import { UpgradeOnboardingModalâ€Ļ │ ││ +│ │ │+11 import { CommandPaletteModalâ€Ļ │ ││ +│ │ │ 12 import { UserInputModalâ€Ļ │ ││ +│ │ └───────────────────────────────────────┘ ││ +│ ╰────────────────────────────────────────────╯│ +└───────────────────────────────────────────────┘ +``` + +Gutter around blocks: `px-3 py-2`. Space between blocks: `gap-2`. Blocks have `rounded-lg`, 1px hairline border, and internal `bg-white/[0.02]`. + +--- + +## 3. Token map (Tailwind) + +Stay within existing tokens; only add the listed utility classes: + +| Role | Class | +| ----------------------------- | ---------------------------------------------- | +| Panel background | `bg-panel` (already in theme; fall back to `bg-[var(--panel-bg)]` if needed — do not hardcode hex) | +| Block surface | `bg-white/[0.025]` | +| Block hover surface | `bg-white/[0.04]` | +| Block border | `border border-white/[0.08]` | +| Block border (expanded) | `border-white/[0.14]` | +| Hairline divider | `border-white/[0.06]` | +| Diff viewport surface | `bg-black/30` | +| Add indicator bar | `bg-emerald-400` | +| Remove indicator bar | `bg-rose-400` | +| Add row tint | `bg-emerald-400/[0.06]` | +| Remove row tint | `bg-rose-400/[0.06]` | +| Hunk header | `text-sky-300/70 bg-sky-400/[0.04]` | +| Accent (branch, focused) | `text-accent` / `bg-accent/80` | + +**Typography** (no font-family changes — just weight/size/tracking): + +| Element | Classes | +| ------------------ | --------------------------------------------------- | +| Panel title | `text-[13px] font-semibold tracking-tight` | +| Branch name | `text-[13px] font-semibold text-primary` | +| Branch sub-label | `text-[11px] text-secondary/70 uppercase tracking-wider` | +| File name | `text-[12px] font-medium text-primary` | +| Directory prefix | `text-[12px] text-secondary/55` | +| Stat counts | `text-[11px] font-mono tabular-nums` | +| Status letter | `text-[10px] font-bold font-mono` | +| Diff line content | `text-[11px] leading-[18px] font-mono` | +| Line numbers | `text-[10px] text-secondary/40 font-mono tabular-nums` | + +--- + +## 4. Component spec + +### 4.1 Title bar (unchanged structure, retuned visuals) + +```tsx +<div className="flex items-center justify-between h-10 px-3 border-b border-white/[0.06] shrink-0"> + <div className="flex items-center gap-2"> + <i className="fa fa-solid fa-code-pull-request text-accent text-[12px]" /> + <span className="text-[13px] font-semibold tracking-tight text-primary">Code review</span> + </div> + <div className="flex items-center gap-1"> + <IconButton icon={isWide ? "fa-compress" : "fa-expand"} onClick={â€Ļ} title={â€Ļ} /> + <IconButton icon="fa-xmark" onClick={â€Ļ} title="Close" /> + </div> +</div> +``` + +`IconButton` is a local helper (define at top of file): + +```tsx +const IconButton = memo(({ icon, onClick, title }: { icon: string; onClick: () => void; title?: string }) => ( + <button + type="button" + onClick={onClick} + title={title} + className="flex items-center justify-center w-6 h-6 rounded text-[11px] text-secondary/70 hover:text-primary hover:bg-white/[0.06] transition-colors cursor-pointer" + > + <i className={cn("fa fa-solid", icon)} /> + </button> +)); +IconButton.displayName = "IconButton"; +``` + +### 4.2 Branch summary card + +Replaces the existing stats bar. Sits in the gutter like any other block so the visual rhythm is consistent. + +```tsx +<div className="mx-3 mt-3 rounded-lg border border-white/[0.08] bg-white/[0.025] px-3 py-2.5"> + <div className="flex items-center justify-between mb-1"> + <div className="flex items-center gap-2 min-w-0"> + <i className="fa fa-solid fa-code-branch text-accent text-[11px] shrink-0" /> + <span className="text-[13px] font-semibold text-primary truncate">{branch || "—"}</span> + </div> + <span className="text-[11px] font-mono tabular-nums text-secondary/70 shrink-0"> + {files.length} {files.length === 1 ? "file" : "files"} + </span> + </div> + <div className="flex items-center justify-between"> + <span className="text-[10px] uppercase tracking-[0.08em] text-secondary/55">Uncommitted</span> + <div className="flex items-center gap-2 font-mono tabular-nums text-[11px]"> + <span className="text-emerald-400">+{totalAdd}</span> + <span className="text-secondary/30">¡</span> + <span className="text-rose-400">−{totalDel}</span> + </div> + </div> +</div> +``` + +Hide the card entirely when `!isRepo`; keep it visible with zeros when the repo is clean. + +### 4.3 File block + +Each file is its own rounded block — NOT a row in a table. + +```tsx +<div + className={cn( + "mx-3 rounded-lg border transition-colors overflow-hidden", + expanded + ? "border-white/[0.14] bg-white/[0.03]" + : "border-white/[0.08] bg-white/[0.02] hover:bg-white/[0.04]" + )} +> + {/* header row */} + <div + className="flex items-center gap-2 h-10 px-3 cursor-pointer group" + onClick={() => fireAndForget(() => model.toggleExpand(file.path))} + > + <i + className={cn( + "fa fa-solid fa-chevron-right text-[9px] text-secondary/50 shrink-0 transition-transform duration-150", + expanded && "rotate-90" + )} + /> + <StatusDot status={file.status} /> + <span className="flex-1 min-w-0 text-[12px] truncate"> + {dir && <span className="text-secondary/55">{dir}/</span>} + <span className="text-primary font-medium">{name}</span> + </span> + <StatBadge add={stats?.add ?? 0} del={stats?.del ?? 0} loading={loading && !stats} /> + <FileActions file={file} /> + </div> + + {/* diff viewport */} + {expanded && ( + <div className="border-t border-white/[0.06] bg-black/30"> + {/* â€Ļsee §4.6 */} + </div> + )} +</div> +``` + +Blocks render in a flex column with `gap-2`; don't reuse `border-b` row separators. + +### 4.4 StatusDot (replaces the single-letter pill) + +A 6px colored dot with a hairline ring. The letter becomes a tooltip. This is the single biggest departure from the current design — it's quieter and matches Warp's language of dot-indicators for command status. + +```tsx +const STATUS_META: Record<string, { color: string; label: string }> = { + M: { color: "bg-amber-400", label: "Modified" }, + A: { color: "bg-emerald-400", label: "Added" }, + D: { color: "bg-rose-400", label: "Deleted" }, + R: { color: "bg-sky-400", label: "Renamed" }, + "??": { color: "bg-secondary/60", label: "Untracked" }, +}; + +const StatusDot = memo(({ status }: { status: string }) => { + const meta = STATUS_META[status] ?? { color: "bg-secondary/60", label: status }; + return ( + <span + title={meta.label} + className={cn("w-1.5 h-1.5 rounded-full shrink-0 ring-1 ring-black/40", meta.color)} + /> + ); +}); +StatusDot.displayName = "StatusDot"; +``` + +### 4.5 StatBadge (retuned) + +Drop the border and the middle dot separator. Align digits with `tabular-nums`. When loading, render a 3-char-wide skeleton so width doesn't jump when the number arrives. + +```tsx +const StatBadge = memo(({ add, del, loading }: { add: number; del: number; loading?: boolean }) => { + if (loading) { + return <span className="w-12 h-3 rounded bg-white/[0.06] animate-pulse shrink-0" />; + } + return ( + <span className="flex items-center gap-1 text-[11px] font-mono tabular-nums shrink-0"> + <span className="text-emerald-400">+{add}</span> + <span className="text-rose-400">−{del}</span> + </span> + ); +}); +StatBadge.displayName = "StatBadge"; +``` + +### 4.6 Diff viewport + +Two changes from the current implementation: + +1. **Left indicator bar instead of a full-row background tint.** A 2px colored bar flush to the left edge of the content area, plus a 6% tint. Much quieter, still scannable. +2. **Line numbers** on the left (mono, tabular, `text-secondary/40`). Use the hunk header to derive numbers; if unavailable, show blanks rather than a prefix character. + +```tsx +const DiffLineRow = memo(({ line, oldNum, newNum }: { line: DiffLine; oldNum?: number; newNum?: number }) => { + if (line.type === "header") return null; // suppress the `diff --git` header — info is already in the block title + if (line.type === "hunk") { + return ( + <div className="px-3 py-1 text-[10px] font-mono text-sky-300/70 bg-sky-400/[0.04] border-y border-white/[0.05]"> + {line.content} + </div> + ); + } + const isAdd = line.type === "add"; + const isDel = line.type === "remove"; + return ( + <div className={cn( + "flex text-[11px] font-mono leading-[18px] relative", + isAdd && "bg-emerald-400/[0.06]", + isDel && "bg-rose-400/[0.06]" + )}> + {(isAdd || isDel) && ( + <span className={cn("absolute left-0 top-0 bottom-0 w-[2px]", isAdd ? "bg-emerald-400" : "bg-rose-400")} /> + )} + <span className="w-8 pl-2 pr-1 text-right text-[10px] text-secondary/40 tabular-nums select-none shrink-0"> + {isAdd ? "" : oldNum ?? ""} + </span> + <span className="w-8 pr-2 text-right text-[10px] text-secondary/40 tabular-nums select-none shrink-0"> + {isDel ? "" : newNum ?? ""} + </span> + <span className={cn( + "whitespace-pre-wrap break-all flex-1 pr-3", + isAdd && "text-emerald-100", + isDel && "text-rose-100", + !isAdd && !isDel && "text-primary/75" + )}> + {line.content} + </span> + </div> + ); +}); +``` + +Wrap lines with a `useMemo` that walks the diff once to compute `(oldNum, newNum)` per line from hunk headers. If that's non-trivial to retrofit (the current `DiffLine` type doesn't carry numbers), it's acceptable to drop both number columns and keep a single-column `+ / − / ¡` prefix as a phase-1 fallback — but the indicator bar + 6% tint change is mandatory regardless. + +### 4.7 File actions + +Currently these appear only on hover, which hurts discoverability. Keep hover-only, but add a subtle always-on affordance: render an "overflow dots" button that's visible at all times; on hover expand the full action row inline. + +If that's too large a change, at minimum bump the icons to `w-6 h-6` touch targets inside an `IconButton` wrapper (same component from §4.1) so users get a hover background and proper cursor, and space them with `gap-0.5` instead of `gap-1.5`. + +### 4.8 Empty / loading / error states + +Match the block rhythm. Each state gets a single centered block with generous padding, a muted icon, and a one-line label. No oversized `text-[32px]` icons — Warp uses 16–20px glyphs even in empty states and lets whitespace do the work. + +```tsx +<div className="flex-1 flex items-center justify-center p-8"> + <div className="flex flex-col items-center gap-3 text-secondary/70"> + <i className="fa fa-solid fa-check text-[18px] text-emerald-400/70" /> + <span className="text-[12px]">Working tree clean</span> + </div> +</div> +``` + +Loading state: replace the spinner + "Loadingâ€Ļ" string with 3 skeleton blocks matching the file-block shape (`h-10 rounded-lg bg-white/[0.03] animate-pulse`), stacked in the gutter. + +--- + +## 5. Behavioral changes + +None that affect `GitModel`. The model stays untouched. All changes are presentational: + +- Auto-refresh, expand/collapse, discardFile, and copy-path flows are preserved verbatim. +- `globalStore_get_cwd` helper (lines 149–157) stays — do not refactor that DOM lookup in this change. +- `WorkspaceLayoutModel.codeReviewWideAtom` continues to drive the `â¤ĸ` toggle. + +--- + +## 6. Acceptance checklist + +- [ ] No hardcoded hex colors remain in `git-panel.tsx` (the `#16161e` on line 184 is replaced). +- [ ] File list renders as a gap-separated stack of rounded blocks, not a bordered row list. +- [ ] Branch summary becomes a card with the `Uncommitted ¡ +N ¡ −N` pattern; no file-count icon. +- [ ] Status letter is replaced by a 6px colored dot with a tooltip. +- [ ] Diff rows use a 2px left indicator bar + 6% background tint (not 30%). +- [ ] All mono numerics use `tabular-nums`. +- [ ] Empty, loading, and error states follow the muted 18px-glyph pattern. +- [ ] Chevron uses `rotate-90` transform with a 150 ms transition (not a swap between two icons). +- [ ] Header icon buttons use the shared `IconButton` component with a hover background. +- [ ] Expanded block gets a brighter border (`border-white/[0.14]`) so it reads as focused. + +--- + +## 7. Out of scope (do not do in this pass) + +- Adding line numbers if `DiffLine` doesn't already expose them — leave a TODO and ship the prefix fallback. +- Changing `git-model.ts` or any atom shape. +- Adding new icons or an animation library. +- Introducing new CSS custom properties in `tailwindsetup.css`. +- Keyboard shortcuts for file navigation (separate proposal). diff --git a/docs/term-engine-migration.md b/docs/term-engine-migration.md new file mode 100644 index 000000000..ec9e8e8c7 --- /dev/null +++ b/docs/term-engine-migration.md @@ -0,0 +1,640 @@ +# Terminal Engine Migration — warp-style command-block renderer + +**Target:** Replace xterm.js-based terminal rendering with a self-contained, +warp-aligned cell-grid engine. +**Code root:** `frontend/app/term/` +**Reference:** `/Users/mac/Documents/open-source/warp/` (Rust source, read-only — do not link) +**Status:** Core engine + `termblocks` view type **complete** (P1–P16, 16 phases). +Single-xterm `term` view type **not yet migrated** (separate workstream). + +--- + +## TL;DR + +We've shipped a from-scratch terminal engine in TypeScript that mirrors warp's +data model (per-block grids, OSC 133-driven state machine, sum-tree-equivalent +cumulative heights for viewport queries) and a React render layer that paints +cells as DOM rows. The `termblocks` view type (block-decomposed terminal) is +fully migrated and live. The simpler single-xterm `term` view type still uses +xterm.js because it has deep VDom / sub-block / agent-overlay integrations +that need to be re-implemented on the new engine before xterm can be removed +from `package.json`. + +This document is the **handoff brief** for completing the migration. It is +written so a future session — possibly with cold context — can resume work +without rediscovering decisions. + +--- + +## Phase status + +| Phase | Scope | Status | +|-------|-------|--------| +| P1 | `engine/types.ts` + `style.ts` + `grid.ts` — Cell / Style / Grid foundations | ✅ done | +| P2 | `block-grid.ts` + `header-grid.ts` + `alt-screen.ts` | ✅ done | +| P3 | `block.ts` + `blocks.ts` — collection w/ cumulative heights | ✅ done | +| P4 | `ansi-parser.ts` + `handler.ts` — 13-state VTE machine | ✅ done | +| P5 | OSC 133 / OSC 8 / OSC 7 dispatch in `block-handler.ts` | ✅ done | +| P6 | `terminal-model.ts` — wps event subscriptions, parser orchestration | ✅ done | +| P7 | React render layer (cell-run, grid, block, list, terminal-view) | ✅ done | +| P8 | Wire `TermBlocksViewModel.viewComponent` → `TerminalViewAdapter` | ✅ done | +| P9 | TUI keyboard routing (`key-bindings.ts` + document keydown) | ✅ done | +| P10 | OSC 8 link click → `getApi().openExternal` | ✅ done | +| P11 | cols ResizeObserver + `sendResize` SIGWINCH | ✅ done | +| P12 | Disable legacy wps / poller in `TermBlocksViewModel` | ✅ done | +| P13 | Selection drag + overlay layer + Cmd+C copy | ✅ done | +| P14 | Find UI (Cmd+F) bound to `BlockGrid.setFilter` | ✅ done | +| P15 | Delete legacy `view/cmdblock/*` files + slim `termblocks.tsx` (2692 → 64 lines) | ✅ done | +| P16 | Uninstall `@xterm/*` packages | ⏸ blocked on **`view/term/` migration** | +| **P17** | **Migrate `view/term/` (single-xterm view) to new engine** | 📋 see [§ Pending work — Track A](#track-a-viewterm-migration) | +| **P18** | **Re-implement VDom toolbar / sub-block / TermStickers on new engine** | 📋 see [§ Pending work — Track B](#track-b-vdom-toolbar--subblocks--stickers) | +| **P19** | **Reconnect term-agent UI to `TerminalModel`** | 📋 see [§ Pending work — Track C](#track-c-term-agent-ui-reconnection) | +| **P20** | **Drop `@xterm/*` and `view/term/*` directory** | 📋 see [§ Pending work — Track D](#track-d-final-cleanup) | + +--- + +## Architecture overview + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ Go: pkg/cmdblock decodes PTY, splits on OSC 133, emits wps events │ +└──────────────────────────────────────────────────────────────────────┘ + ↓ + cmdblock:row / chunk / altscreen / clear (wps over websocket) + ↓ +┌──────────────────────────────────────────────────────────────────────┐ +│ TerminalModel (frontend/app/term/terminal-model.ts) │ +│ - owns Blocks collection │ +│ - owns one AnsiParser + one BlockHandler │ +│ - swaps handler.setBlock(target) per chunk │ +│ - jotai atoms: revision / selection / scrollPos / find / loading │ +└──────────────────────────────────────────────────────────────────────┘ + ↓ +┌──────────────────────────────────────────────────────────────────────┐ +│ AnsiParser → BlockHandler → Block.activeGrid() │ +│ OSC 133;A/B/C/D → block.startPrompt/endPrompt/Cmd/finishCommand │ +│ CSI m → applySgr → grid.setStyle │ +│ CSI ?1049 h/l → block.enterAltScreen / exit │ +│ OSC 8 → grid.addLink + style.linkId │ +└──────────────────────────────────────────────────────────────────────┘ + ↓ +┌──────────────────────────────────────────────────────────────────────┐ +│ Grid mutate (rows: Cell[][], cursor, dirtyRows) │ +│ BlockGrid.finish() → memoize rightmostNonempty / hasVisibleChars │ +│ HeaderGrid: dual grid + CellExtra anchors (promptStart/End/CmdStart)│ +└──────────────────────────────────────────────────────────────────────┘ + ↓ + TerminalModel.bumpRevision() + ↓ + useAtomValue(revisionAtom) → React re-renders + ↓ +┌──────────────────────────────────────────────────────────────────────┐ +│ React render (frontend/app/term/render/) │ +│ TerminalView → BlockListElement → BlockElement → GridElement │ +│ computeRuns merges cells by style identity → CellRun <span>s │ +│ SelectionLayer paints overlay rectangles (never touches cells) │ +│ FindBar binds Cmd+F to BlockGrid.setFilter │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### Key invariants + +1. **Renderer never mutates cells.** Selection, find-match highlights, secrets + are *overlay layers* drawn from metadata. The cell stream is immutable + from React's perspective. +2. **One parser per terminal.** `BlockHandler.setBlock(target)` swaps the + write destination on each chunk event. Out-of-order chunks are dropped + via `writtenOffsets` map; the 10 s safety poll repairs gaps. +3. **OSC 133 drives lifecycle.** Block state never gets inferred from byte + content — only explicit markers transition. This is what makes warp + robust across shells. +4. **Per-block grids, not continuous scrollback.** Each Block owns + `headerGrid` (dual: prompt + prompt+command) + `outputGrid` + `altScreen`. + This is the load-bearing trade-off: gives clean serialization / + share-block / AI-context boundaries; costs cross-block search which we + handle at the collection level (`Blocks.all()` iteration). +5. **Cumulative-height array ≡ warp's SumTree.** `O(log N)` viewport + queries via binary search. Rebuild on mutation is `O(N - i)` from the + modified index forward; amortized `O(1)` for the common tail-growth case. + +### What's deliberately **not** like warp + +- **No custom GPU renderer.** DOM rows + Tailwind. Warp's bespoke Rust+GPU + pipeline is a multi-month rebuild; xterm.js in Electron costs us almost + nothing in perf for typical terminal output (rows are static once + rendered; only the streaming tail mutates). +- **No SumTree.** TS array + cumulative sums has identical asymptotic + complexity for viewport range queries; only the random-position + `updateHeight` is `O(N - i)` vs SumTree's `O(log N)`. In practice we + almost always update the tail. +- **No `OnceLock`.** TS uses lazy memoization via plain `?:` cache fields + on `BlockGrid` (cleared at finish). + +--- + +## What's live (post-P16) + +Behavior parity with warp for the `termblocks` view type: + +| Feature | Status | Notes | +|---|---|---| +| Block-decomposed output (OSC 133) | ✅ | A/B/C/D fully wired | +| ANSI SGR colors (16 / 256 / RGB) | ✅ | `engine/style.ts` | +| Cursor positioning (CUP / CUU / CUD / CUF / CUB / CHA / VPA) | ✅ | `block-handler.ts` | +| Erase (EL / ED) | ✅ | inherits current bg color (xterm convention) | +| Scroll (SU / SD) | ✅ | | +| Save/restore cursor (DECSC/DECRC, ESC 7/8, CSI s/u) | ✅ | | +| Alt-screen (CSI ?1049 h/l) | ✅ | block-inline (warp-style); vim/htop/less/lazygit usable | +| TUI keyboard routing | ✅ | `key-bindings.ts` covers arrows / F1-F12 / Home/End/PgUp/PgDn / Ctrl-letters / Alt prefix | +| OSC 8 hyperlinks (click to open) | ✅ | wires to `getApi().openExternal` | +| OSC 7 cwd tracking | ✅ | `block.pwd` | +| Wide chars (CJK, emoji) | ✅ | basic UCD ranges; full UAX-11 deferred | +| UTF-8 streaming decode | ✅ | TextDecoder with `{stream: true}` handles split sequences | +| Mouse drag selection | ✅ | overlay layer, never touches cells | +| Cmd+C copy | ✅ | extracts text from selected range, trims trailing whitespace | +| Cmd+F find | ✅ | bound to per-block regex filter | +| Scroll-to-bottom follow / pause-on-scroll-up | ✅ | `ScrollPosition` enum drives behavior | +| Snackbar sticky header on scroll | ✅ | IntersectionObserver | +| Hover toolbelt (copy / AI / save / filter / bookmark / share / more) | ✅ visual | only copy + ask-AI wired; rest are stubs | +| Window-resize cols + SIGWINCH | ✅ | ResizeObserver + measured char width | +| Done-block immutability + memo | ✅ | `BlockGrid.finish()` triggers lazy cache | +| Inline link tooltip on hover | âš ī¸ partial | URI shown in `title` attr; no rich popover | +| Vim/htop/less | ✅ functional | basic verified mentally — manual smoke test required | + +### What's **not** wired but **does** have UI + +These exist as JSX but the click handler is empty — they're the next layer +of polish, not full bugs: + +- Toolbelt **Save Workflow** button +- Toolbelt **Filter** button (filter-per-block exists, no UI gate) +- Toolbelt **Bookmark** button +- Toolbelt **Share** button +- Snackbar **Jump back** click (sets scroll anchor; needs scrollTo logic + in BlockListElement) +- Input bar **Model picker** dropdown +- Input bar **Suggestions** row (data: `historyAtom` was loaded in legacy + path; need to re-route to new model) + +--- + +## Pending work + +### Track A — `view/term/` migration + +The single-xterm `term` view type (regular terminal pane, no command-block +decomposition) is still on xterm.js. It cannot be deleted because: + +- `BlockRegistry.set("term", TermViewModel)` in `frontend/app/block/blockregistry.ts` +- `frontend/app/store/tabrpcclient.ts` imports `TermViewModel` (type-only, but still a load) +- `frontend/app/block/durable-session-flyover.tsx` mounts `TermViewModel` + +**Files in scope** (current): + +``` +frontend/app/view/term/ +├── term.tsx (~410 LOC) main view component +├── term-model.ts (~1200 LOC) TermViewModel — atoms, RPC plumbing +├── termwrap.ts (~600 LOC) xterm instance wrapper +├── termutil.ts (~350 LOC) theme + helpers +├── term-agent.tsx (~810 LOC) AI overlay anchored on the term view +├── term-settings-menu.ts (~150 LOC) gear menu builder +├── term-tooltip.tsx (~80 LOC) OSC 8 hover preview +├── termsticker.tsx (~120 LOC) workspace sticker overlay +├── term-wsh.tsx (~190 LOC) wsh control integration +├── osc-handlers.ts (~340 LOC) custom OSC dispatcher (legacy) +├── fitaddon.ts (~30 LOC) @xterm/addon-fit wrapper +├── term.scss + xterm.css styling +└── termtheme.tsx (in `view/term/`? resolve in P17 kickoff) +``` + +**Two viable strategies:** + +| Strategy | Cost | Result | +|---|---|---| +| **A1 — Wrap `TerminalView`** | ~2 days | Replace `TermViewModel.viewComponent` with an adapter that renders `<TerminalView outerBlockId={blockId} />`. Single big block, no OSC 133 needed. Loses the directly-interactive xterm — input goes through the model's `submitInput` path. | +| **A2 — Native "raw" mode in engine** | ~4–5 days | Add a `Block.mode = "raw"` variant where the entire block is one continuous stream (no prompt/command split, no exit-code marker, no command finalization). Cleaner long-term — matches warp's `Block::Static`. | + +Recommend **A1 first** (fast unblock of P16), revisit A2 if interactive +performance suffers. + +**A1 detailed plan:** + +1. In `frontend/app/view/term/term-model.ts`: + - Remove all xterm-related fields (`termRef`, `connStatus`, etc. — KEEP + since other modules read them as TS types; mark fields optional). + - Add `viewComponent` getter that returns a new adapter (parallel to + how `TermBlocksViewModel` does it). +2. Create `frontend/app/view/term/term-adapter.tsx`: + - Pulls `blockId` from the model. + - Renders `<TerminalView outerBlockId={blockId} />`. +3. Verify `durable-session-flyover.tsx` still mounts — the flyover may + reach into `model.termRef` for things like `focus()`; add equivalents + on `TerminalModel` (`focus()`, `blur()` no-ops are OK initially). +4. Verify `store/tabrpcclient.ts` — read-only type import, should be safe. +5. Remove the xterm.js mount path from `term.tsx`. The whole file becomes + the adapter or a thin wrapper. +6. tsc, run `npm run dev`, verify a "term" block (plain `bash`, `python`, + `ssh somewhere`) renders correctly. + +**Risks:** + +- The "term" view supports interactive REPLs that don't emit OSC 133 + (python, node, ssh remote sessions). The new engine's block-finish + detection relies on OSC 133;D — without it, the "block" never finishes, + the running spinner stays forever, and any "scroll-to-bottom" behavior + may misfire. +- **Mitigation:** introduce a `Block.markStatic()` path that treats the + whole session as one ongoing block. Suppress lifecycle UI (no exit-code + badge, no duration timer) when `block.isStatic === true`. + +**Acceptance:** + +- `bash` opened in a "term" view shows output ✓ +- Arrow-up history works (because keys route through new model's `sendBytes`) ✓ +- `python` REPL — typing `print(1)` shows `1` ✓ +- `ssh somehost` — connection works, prompt visible ✓ +- `vim file` — alt-screen takes over the block ✓ + +### Track B — VDom toolbar / sub-blocks / TermStickers + +These are features anchored on `term.tsx` that the new engine doesn't (yet) +support: + +**VDom toolbar:** +- Custom UIs rendered above the terminal via `pkg/vdom` Go-side framework +- Triggered by `term:mode = "vdom"` block meta + a VDom block ID +- Renders custom components (forms, charts, etc.) inline with the terminal + +**Sub-blocks:** +- Wave's mechanism for nesting blocks inside a parent block +- `<SubBlock />` import in `term.tsx` +- Used for the VDom toolbar mount point + +**TermStickers:** +- Workspace-level customization overlays (decorative) +- `<TermStickers />` import in `term.tsx` + +**Migration plan (P18):** + +Each of these surfaces is independent of the cell-grid renderer. They mount +*around* the terminal, not *inside* it. The migration is mechanical: + +1. After Track A (`TerminalView` is the term view's renderer), add slots + to `TerminalView`: + ```tsx + interface TerminalViewProps { + outerBlockId: string; + fontSize?: number; + topSlot?: React.ReactNode; // for VDom toolbar + bottomSlot?: React.ReactNode; // for stickers + overlaySlot?: React.ReactNode; + } + ``` +2. The term-adapter component reads `term:mode`, `term:vdomblockid` etc. + and threads the appropriate child components into the slots. +3. Test each feature flag: + - Default term (no mode): no slots, plain rendering + - VDom mode: top slot shows VDom subblock + - Workspace stickers visible: bottom slot mounts `<TermStickers />` + +**Risks:** + +- VDom subblocks have their own focus / event model. Wiring them through + the new view's keyboard router (which captures keys for alt-screen) may + cause conflicts. **Mitigation:** scope the document-level keydown + listener to elements *inside* `rootRef.current` only. +- TermStickers may have positioning that depends on xterm's measured + geometry. New engine measures differently. **Mitigation:** use the + measured `charWidth` from `TerminalView` as the geometry source. + +### Track C — term-agent UI reconnection + +`view/term/term-agent.tsx` (810 LOC) is the AI agent chat overlay that +appears on top of the terminal. It reads: + +- `TermViewModel.termAgentVisible` atom +- `TermViewModel.termAgentInput` atom +- `TermViewModel.termAgentChatStatus` atom +- `TermViewModel.termAgentSendMessage` callback (wires to ai-sdk's useChat) +- `TermViewModel.termAgentPosture` (permissions posture) +- Plus ~8 more atoms + +**Migration plan (P19):** + +1. Decide ownership: should agent state live on `TerminalModel` (per-pane) + or `WorkspaceLayoutModel` (per-workspace)? **Recommend:** per-pane, + matching the legacy contract. +2. Add agent atoms to `TerminalModel`: + ```ts + agentVisibleAtom: PrimitiveAtom<boolean> + agentInputAtom: PrimitiveAtom<string> + agentChatStatusAtom: PrimitiveAtom<string> + agentEntriesAtom: PrimitiveAtom<AgentEntry[]> + agentPostureAtom: PrimitiveAtom<string> + // ... + ``` +3. Move `term-agent.tsx` to `frontend/app/term/render/agent-overlay.tsx`. + It becomes a child of `TerminalView` rendered above the block list. +4. The agent's chat timeline interleaves with command blocks. Two design + choices: + - **C1:** Agent messages render as a separate scroll region above / + below the block list (legacy behavior). + - **C2:** Agent messages become a new `Block.kind = "agent"` variant + in the same block list, sorted by timestamp (warp's design — see + warp's `TimelineEntry` enum). + Recommend **C2** for parity with warp's "Block::AgentResponse" pattern. +5. ai-sdk's `useChat` needs to be hosted by `TerminalView` (was hosted by + the legacy `TermAgentChatProvider`). Same wiring, new home. +6. `term-agent-tool-renderer.tsx` and other tool-call UIs stay in their + current files — only the model references update. + +**Risks:** + +- ai-sdk's chat state is independently reactive (`useChat` is a hook). + Threading it through `TerminalModel`'s mutation-based state may cause + duplicate renders. **Mitigation:** keep useChat in the React component + layer; expose the model's atoms only for stable references (input + string, posture). The model never sees ai-sdk message objects directly. +- Tool-call approval UIs (`TermAgentApprovalContext`) are global per-tab. + Already provider-based; should just work after moving the provider to + the new view. + +### Track D — Final cleanup + +Once A + B + C are done: + +1. `git rm -r frontend/app/view/term/` +2. Edit `frontend/app/block/blockregistry.ts` to point "term" view type + at the new `TermBlocksViewModel` (or rename to `TerminalViewModel`). +3. Remove `TermViewModel` import + reference from `store/tabrpcclient.ts` + (type-only — safe). +4. Update `block/durable-session-flyover.tsx` to use the new model. +5. `frontend/package.json`: remove + ``` + "@xterm/xterm", + "@xterm/addon-fit", + "@xterm/addon-search", + "@xterm/addon-serialize", + "@xterm/addon-web-links", + "@xterm/addon-webgl" + ``` +6. `npm install` to update lockfile. +7. Optional: rename `view/termblocks/termblocks.tsx` → + `view/termblocks/terminal-view-model.ts` since the legacy "termblocks" + name only exists for back-compat with serialized block.meta.view values. + +--- + +## File inventory (current) + +``` +frontend/app/term/ 4480 LOC +├── engine/ 2673 LOC +│ ├── types.ts 191 +│ ├── style.ts 194 +│ ├── grid.ts 380 +│ ├── block-grid.ts 192 +│ ├── header-grid.ts 161 +│ ├── alt-screen.ts 86 +│ ├── block.ts 206 +│ ├── blocks.ts 253 +│ ├── handler.ts 58 +│ ├── ansi-parser.ts 544 +│ ├── block-handler.ts 387 +│ └── index.ts 21 +│ +├── render/ 1147 LOC +│ ├── color.ts 61 +│ ├── cell-run.tsx 90 +│ ├── grid-element.tsx 156 +│ ├── block-element.tsx 234 +│ ├── block-list-element.tsx 159 +│ ├── terminal-view.tsx 248 +│ ├── selection.ts 83 +│ ├── selection-layer.tsx 76 +│ ├── find-bar.tsx 70 +│ ├── key-bindings.ts 111 +│ └── index.ts 20 +│ +├── terminal-model.ts 455 +└── index.ts 10 + +frontend/app/view/termblocks/ 64 LOC (was 2692) +└── termblocks.tsx 64 compat shim → TerminalViewAdapter + +frontend/app/view/cmdblock/ 300 LOC (visual atoms reused) +├── cmdblock-header.tsx ~90 prompt-row composition +├── cmdblock-toolbelt.tsx ~85 hover overlay +├── cmdblock-snackbar.tsx ~65 sticky pinned header +├── cmdblock-input.tsx ~145 bottom prompt +└── cmdblock-status.tsx ~95 state mapping + duration formatting + +frontend/app/asset/ui-icons/ 66 SVGs (warp icons, currentColor) + +frontend/app/element/ui-icon.tsx 30 LOC generic SVG icon component +``` + +**Deleted this initiative:** + +- `frontend/app/view/termblocks/termblocks.scss` (legacy CSS) +- `frontend/app/view/cmdblock/cmdblock-output.tsx` (old xterm wrapper) +- `frontend/app/view/cmdblock/cmdblock-altscreen.tsx` (old xterm wrapper) +- `frontend/app/view/cmdblock/cmdblock-item.tsx` (used cmdblock-output) +- `frontend/app/view/cmdblock/cmdblock-list.tsx` (used cmdblock-item) +- ~2628 lines of legacy `TermBlocksView` + helpers from `termblocks.tsx` + +**Total LOC delta for the migration so far:** + +``` ++4480 LOC new term/ engine + render layer ++ 300 LOC reused cmdblock/ visual atoms +- 2628 LOC legacy termblocks.tsx body +- ~4000 LOC 4 deleted cmdblock files (estimate) +───────── +net ≈ -1850 LOC + a from-scratch warp-style engine +``` + +--- + +## Risk map + +| Risk | Surface | Mitigation | +|---|---|---| +| OSC 133 not emitted by some shells | Track A's "raw" terminals | `Block.isStatic` path, suppress lifecycle UI | +| Wide-char width detection misses some Unicode ranges | Engine `grid.ts:isWide` | Currently covers CJK + emoji blocks; expand on user-reported issues | +| Tmux pass-through DCS sequences | `ansi-parser.ts` | DCS dispatch exists but not consumed; will manifest as missing tmux features. Add tmux pass-through in P21 | +| Bracketed paste | Input handling | Engine drops DEC 2004 toggle. CmdBlockInput needs to wrap pasted text in `\x1b[200~`...`\x1b[201~` when shell announced bracketed-paste support | +| IME composition events | TUI keyboard routing | Currently `keyEventToBytes` ignores composition. Chinese / Japanese input in vim will be broken until we add composition event handling | +| Selection across multiple blocks | `selection.ts` | Currently single-block. Cross-block selection requires a global layout model | +| Find performance with 10k+ blocks | `BlockGrid.setFilter` over all blocks | O(N) iteration on every keystroke. Will start being noticeable around 1k blocks; add debounce + worker-thread regex if it becomes a problem | +| Resize during alt-screen | `sendResize` SIGWINCH | TUIs may not re-render until next user keystroke. Test with htop | +| Theme changes mid-stream | `resolveColor` reads CSS vars | Should just work because we don't bake colors — but verify by toggling themes with vim running | + +--- + +## Testing plan + +### Smoke tests (manual) + +After any Track A/B/C work, run these: + +1. **Plain shell:** + ```sh + echo "hello" + ls -la /tmp + for i in 1 2 3; do echo $i; sleep 0.5; done + ``` +2. **Streaming output:** + ```sh + tail -f /var/log/system.log + ``` + (Stop with Ctrl-C — verify SIGINT routed) +3. **Long output (scrollback):** + ```sh + seq 10000 + ``` + Verify scroll, scroll-up pauses follow-bottom, scroll-down resumes. +4. **ANSI colors:** + ```sh + printf '\e[31mred\e[32mgreen\e[34mblue\e[0m\n' + printf '\e[38;5;202morange-256\e[0m\n' + printf '\e[38;2;255;100;0mtrue-color\e[0m\n' + git status # uses ANSI green/red + ``` +5. **Alt-screen TUI:** + ```sh + vim README.md # navigate with j/k, exit with :q + htop # exit with q + less /etc/passwd # scroll, exit with q + ``` +6. **OSC 8 link:** + ```sh + printf '\e]8;;https://github.com\e\\GitHub\e]8;;\e\\\n' + ``` + Click — should open in browser. +7. **OSC 133 shell integration:** + ```sh + # Verify each block has prompt + command + output split + # Verify exit-code badge after a `false` command + false + true + ``` +8. **Selection + copy:** + - Drag-select across a few lines. + - Cmd+C. + - Paste into another app — verify lines + no trailing whitespace. +9. **Find:** + - Cmd+F. + - Type "error". + - Verify non-matching blocks hide. + - Esc — verify all blocks return. +10. **Resize:** + - Drag the window narrower / wider. + - Verify cols updates; `tput cols` in shell reports the new value. +11. **CJK / Emoji:** + ```sh + echo "äŊ åĨŊä¸–į•Œ 🚀" + ``` + Verify glyphs render at the right width. + +### Regression checklist (post-Track A) + +- "term" view type renders shells the same way +- VDom toolbar (if any block uses `term:mode = "vdom"`) appears +- Durable session flyover opens +- Workspace stickers render +- Agent overlay opens via Cmd+I (or whatever key) — text input works + +--- + +## Rollback strategy + +Three levels of granularity: + +1. **Per-view-type rollback:** in `frontend/app/view/termblocks/termblocks.tsx`, + delete the entire body and `git checkout HEAD~N -- ...` from before the + slim-down. This restores the legacy xterm-based view *for the termblocks + view type*. The flag `NewEngineEnabled` was removed when we slimmed the + file, so a true rollback requires reverting the slim-down commit. + +2. **Engine-level rollback:** if a specific engine bug surfaces, fall back + per-block by setting `block.isStatic = true` (skips lifecycle / agent + state, renders raw cells). Useful for "this shell breaks the engine" + diagnostics. + +3. **Repo-level rollback:** the engine lives in `frontend/app/term/`. To + completely undo this initiative, `rm -rf frontend/app/term/`, restore + `termblocks.tsx` from git, and revert `viewComponent` to return the + legacy view. View `git log frontend/app/view/termblocks/termblocks.tsx` + for the pre-slim version. + +--- + +## Decision log + +Significant choices made during the build, kept here so future +maintainers don't relitigate: + +| Decision | Why | Alternative considered | +|---|---|---| +| DOM rows, not canvas | React reconciliation is sufficient at 60fps for typical terminal output; canvas would force us to re-implement selection, accessibility, copy/paste | xterm.js webgl renderer — but that's exactly what we're moving away from | +| One AnsiParser, swap target | Shell output is one continuous byte stream; warp does the same | Per-block parsers — would require splitting the stream at OSC 133 boundaries in Go | +| Per-block xterm dropped | OSC 133 already segments the byte stream; cell-grid is cheaper than spinning xterm per block | Keep xterm per block (the failed P0–P5 iteration we tried then abandoned) | +| Sparse rows (`Cell[]` may be < cols) | Memory-friendly for typical short output rows; renderer treats trailing positions as implicit blanks | Full rows (`Cell[cols]`) — costs ~3× memory on real workloads | +| Style reference equality + deep fallback | Common case is unchanged SGR → identical CellStyle reference, cheap `===` merge | Always deep equality — measurable hot-path cost in `computeRuns` | +| Cumulative-height array, not SumTree | TS SumTree implementation is a few hundred lines of code for marginal asymptotic benefit at our scale | Port warp's `sum_tree` crate — sure if we ever exceed 1k blocks per terminal | +| Document-level keydown for alt-screen | TUIs expect global keystroke capture; element-level listeners require focus management we don't want to fight | Element-focused listener on the active block — falls down when input bar has focus | +| Char width measured per-pane via hidden probe | Different themes / zoom levels change char width; one constant fails | Hardcoded `fontSize * 0.6` — visibly wrong for some monospace fonts | +| OSC 133;P key=value parsing | Some shells emit `OSC 133;P;cwd=/foo;user=alice` for richer context | Drop OSC 133;P — would lose cwd info from shells that prefer it over OSC 7 | +| Selection per-block | Cross-block is a separate UX question (and DOM challenge); single-block is the common case | Global selection — DOM range API doesn't slice cleanly across our row divs | +| Find applies to *all* blocks | History search is the main use case | Visible-block-only — would surprise users who scrolled past the match | + +--- + +## Glossary + +- **Block** — one command invocation, from prompt to exit code. Owns header + grid, output grid, alt-screen. Equivalent to warp's `Block`. +- **BlockGrid** — wrapper around `Grid` with lifecycle (`started` / + `finished`) and memoized post-finish queries. +- **HeaderGrid** — dual-grid pair (prompt + prompt+command) for the row + above the output. Stores the prompt/command demarcation as a + `CellExtra.commandStart` flag. +- **AltScreen** — the alternate buffer for TUI apps. Block-inline (warp + design), not fullscreen. +- **CellExtra** — per-cell metadata that doesn't belong on the style + reference: OSC 133 anchors, image ids, secret-redaction flag. +- **Run** — a contiguous slice of cells in a single row that share a style + reference. Renderer emits one `<span>` per run. +- **OSC 133** — shell integration sequences. A = start prompt, B = end + prompt, C = start command (exec), D[;exitcode] = command finished. + `iTerm2` / `WezTerm` / `kitty` / `Warp` all use these. +- **VTE** — the reference state-machine table at + `https://vt100.net/emu/dec_ansi_parser` — what our parser implements. +- **SumTree** — warp's persistent balanced tree for `O(log N)` viewport + queries. We use a cumulative-height array for the same big-O. + +--- + +## How to resume from this doc + +If you're picking this up cold: + +1. Read TL;DR + Phase status table. +2. Read the architecture diagram. +3. Pick a track (A / B / C / D) — they have explicit dependencies (A blocks + B and D; C is independent of A). +4. Open the relevant files listed in the track's plan. +5. Run `npx tsc --noEmit` after each substantive change. +6. Smoke-test from the [Testing plan](#testing-plan) list. + +The engine itself (`frontend/app/term/engine/`) should not need changes for +A/B/C — those tracks are integration work in the view layer. If you find +yourself editing `engine/` to complete a track, that's a signal to step +back and check whether you've understood the boundary right. + +--- + +_Document version: 1.0 — created at the end of the P1–P16 migration sprint._ +_Total active phases completed: 16 / 20._ diff --git a/docs/terminal-bench-gap-analysis.md b/docs/terminal-bench-gap-analysis.md new file mode 100644 index 000000000..869839e11 --- /dev/null +++ b/docs/terminal-bench-gap-analysis.md @@ -0,0 +1,225 @@ +# Terminal-Bench 2 Gap Analysis — Crest vs ForgeCode + +**Status:** Draft for review. Implementation has not started. **2026-04-26**. + +## TL;DR + +Crest cannot run on Terminal-Bench 2 today — there's no harness adapter, no Docker-container execution model, and `shell_exec` is single-shot rather than session-resident. Even after fixing those plumbing gaps, the agent's loop is missing several scaffold features that empirically separate ~60% scoring agents from ~75% scoring agents on TB2: doom-loop detection, tool-error reflection, todo-state enforcement, AI-summarized compaction, ripgrep/glob tools, and a verification discipline prompt. + +**Realistic target:** ~70–72% on TB2 with `Claude Opus 4.7` + a clean Crest-Harbor scaffold. ForgeCode reports 81.8% but a third-party audit ([debugml.github.io/cheating-agents](https://debugml.github.io/cheating-agents/)) found their submitted traces relied on `AGENTS.md` answer keys; clean rerun was ~71.7%. Treat ~72% as the credible ceiling, not 82%. + +**Reference benchmarks** (cleaner comparisons): +- Codex CLI + GPT-5.2 = 62.9% (paper baseline) +- Claude Opus 4.5 + Terminus-2 = 57.8% (paper baseline, the canonical "simple loop") +- Top scaffolds with the same model = +7–17pp over Terminus-2 + +The score-relevant work is *all in the agent loop and tool surface*, not in UI/UX. + +--- + +## What Terminal-Bench 2 actually tests + +89 tasks, each shipping as `(Dockerfile + instruction.md + tests/test.sh + oracle solution)`. Per-task: +- Wall-clock budget ~30 min (`task.toml` `[agent].timeout_sec=1800`) +- Resource cap `cpus=1 memory=2G storage=10G` +- Binary scoring — `tests/test.sh` exits zero or it doesn't. + +Failure-mode breakdown (Snorkel + paper): +1. **Execution errors (~34%)** — command-not-found / missing executables / runtime errors +2. **Coherence errors (~25%)** — agent forgets prior file edits, repeats subgoals, drifts off plan +3. **Verification errors (~25%)** — agent declares "done" without running the tests + +What top scaffolds (Terminus-KIRA, Forge, Droid) all add over a vanilla loop: +- **Persistent tmux session** for long-running processes (servers, builds) +- **Pre-execution plan** the model writes and commits to +- **Pending-todos guard** preventing premature "done" +- **Tool-error reflection** before retry +- **Doom-loop detection** when same call repeats N times + +Things that don't move the score: diff UIs, syntax highlighting, browser tools, file previews. The benchmark is shell-throughput-limited. + +--- + +## Crest baseline (current state) + +Tooling and loop, with file references: +- 17 core tools, 3 modes (`ask`/`plan`/`do`) — `pkg/agent/registry.go`, `pkg/agent/modes.go:41-125` +- 50-step hard budget — `pkg/agent/agent.go:28` (`DefaultMaxAgentSteps=50`) +- 100k token compaction trigger at 80% — `pkg/aiusechat/usechat.go:576-581` +- Compaction = drop middle, keep first 1 + last 10 messages (no AI summary) — `pkg/aiusechat/chatstore/chatstore.go` +- Parallel tool calls (read-only only) — `pkg/aiusechat/usechat.go:586` +- Anthropic prompt caching (system prompt + rolling) — `pkg/aiusechat/anthropic/anthropic-convertmessage.go` +- Git worktree sandbox — `pkg/agent/sandbox.go` +- Dangerous-cmd guard (15 regex patterns) — `pkg/agent/tools/dangerous.go:25-59` +- SSRF-protected web_fetch — `pkg/agent/tools/web_fetch.go` +- Sub-agent (`spawn_task`, 15-step / 120s budget) — `pkg/agent/tools/spawn_task.go` +- File checkpoint + rewind — `pkg/agent/checkpoint.go` +- Four backends (Anthropic / OpenAI Responses / OpenAI Completions / Gemini) — `pkg/aiusechat/usechat-backend.go` +- System prompt = `prompts/shared_header.md` + `prompts/{ask,plan,do}.md` — `pkg/agent/prompts.go:30-49` + +Tools available in `do` mode: `read_text_file`, `read_dir`, `get_scrollback`, `cmd_history`, `web_fetch`, `write_text_file`, `edit_text_file`, `shell_exec`, `write_plan`, `create_block`, `focus_block`, 4 browser tools, `spawn_task`, plus dynamic MCP tools. + +--- + +## Gap matrix — Crest vs ForgeCode + +Organized by impact on TB2 score. Each row: what's missing, why it matters, where to add it. + +### Critical — blocks scoring entirely + +| # | Gap | Crest current | ForgeCode | Where this lives | +|---|---|---|---|---| +| C1 | **No Harbor harness adapter** | None — Crest is a desktop UI agent only | Custom agents register a Python `BaseAgent` class (`harbor/src/harbor/agents/base.py`); Forge ships its own benchmark harness in `benchmarks/` | New: `benchmarks/harbor-adapter/` driving Crest's Go core via stdin/stdout JSON | +| C2 | **shell_exec is single-shot** — each call spawns a fresh subprocess | `pkg/agent/tools/shell_exec.go:57-130`. No way to keep a server running across calls; no shared shell state | Forge's `shell` tool also single-shot, but the **benchmark scaffolds (Terminus-2, Terminus-KIRA) use tmux** — `harbor/src/harbor/agents/terminus_2/tmux_session.py` is the reference | New tool: `pty_session` (open / send / read / close) backed by a long-lived `bash` or tmux pane. Termblocks already manage PTYs; reuse the plumbing | +| C3 | **Host-only execution** — tools touch the host filesystem | `shell_exec`, `read_text_file`, `write_text_file` all hit local fs | TB2 runs each task in a Docker container; agent must operate **inside** the container | Adapter (C1) must shell into the container; tool calls become `docker exec` style | +| C4 | **No "headless / non-interactive" mode** — tool approvals require UI | `pkg/agent/toolapproval.go` blocks until SSE approval message; sub-agent dangerous commands time out at 120s with no UI | Forge has `restricted` mode + `permissions.default.yaml` policy; runs benchmarks unattended | New: bench mode that auto-approves all non-dangerous tools and *runs* dangerous ones in a hardened sandbox (since the Docker container is itself the sandbox). `pkg/agent/modes.go` needs a `bench` mode | + +### Important — moves score significantly + +| # | Gap | Crest current | ForgeCode | Where this lives | +|---|---|---|---|---| +| I1 | **No ripgrep/grep tool** — model uses `shell_exec` for search, which is approval-gated and noisy | No `pkg/agent/tools/search.go` | `fs_search` with regex + glob + multiline + 3 output modes; description forbids `rg`/`grep` via shell — `crates/forge_domain/src/tools/descriptions/fs_search.md` | New: `pkg/agent/tools/search.go` wrapping `rg --json`. Auto-approved (read-only). Mark `Parallel: true` | +| I2 | **No glob tool** | Folded into shell_exec | Folded into `fs_search` via `glob` parameter | Add `glob` parameter to the new `search` tool | +| I3 | **No multi-edit** — model has to call `edit_text_file` N times for N hunks | `pkg/agent/tools/edit_text_file.go` does one range replace | `multi_patch` is atomic batch (all-or-nothing) | New: `pkg/agent/tools/multi_edit.go` taking `[]EditOp{old_string, new_string, replace_all?}`, applies sequentially in memory, single backup, single approval | +| I4 | **No doom-loop detection** | None — model can repeat the same failed call indefinitely until the 50-step cap | `templates/forge-doom-loop-reminder.md` injected when `consecutive_calls` exceeds threshold | Add to step loop in `pkg/aiusechat/usechat.go:506-596`: hash recent (tool_name, args) tuples; if N identical in window, append a system message warning | +| I5 | **No tool-error reflection** | Tool errors surface as plain text; model often blindly retries | `templates/forge-partial-tool-error-reflection.md` forces *"deeply reflect: pinpoint exactly what was wrongâ€Ļ explain why that mistake happened"* | After a failed tool call, prepend a reflection nudge to the next tool result. Implement in `pkg/aiusechat/usechat.go` tool-result conversion | +| I6 | **No `todo_write` / `todo_read` tools with pending-todos guard** | Model self-tracks in text only | Stateful diff-update todos; **pending-todos guard prevents declaring done** with open items — `templates/forge-pending-todos-reminder.md` | New: `pkg/agent/tools/todo.go`. State stored in chat session. Guard: when model emits a "stop" signal with open items, inject a reminder and continue the loop | +| I7 | **Compaction loses state** — drops middle, no summary | `keepFirst=1, keepLast=10` (`pkg/aiusechat/chatstore/chatstore.go`) | Compactor renders structured summary stubs per tool kind (`**Update:** path`, `**Read:** path`, `**Search:** pattern`); deduces "last operation per file path"; preserves Anthropic reasoning_details across compaction — `crates/forge_app/src/compact.rs` | Rewrite compaction in `pkg/aiusechat/chatstore/chatstore.go`. Build a summary message from compacted range using a template like Forge's `forge-partial-summary-frame.md`. For Anthropic: copy last `reasoning_details` into first surviving assistant message | +| I8 | **No verification step in prompt** | `do.md` says *"verify them: run tests"* but not enforced | Forge: `forge.md` has *"Implementation Methodology: 4. Quality Assurance — Validate changes through compilation and testing"* + pending-todos guard forces it | Tighten `prompts/do.md` to require a verify step. Combine with I6 — auto-add a "verify with tests" todo after first mutation | +| I9 | **No AGENTS.md auto-prepend** | Not loaded; agent reads CLAUDE.md/CONVENTIONS.md only if explicitly read | Forge auto-injects `<project_guidelines>` from `AGENTS.md` at repo root — `templates/forge-custom-agent-template.md` | Add to `pkg/agent/context.go` `BuildTerminalContext`: scan cwd for AGENTS.md (and CLAUDE.md as fallback) and inject as `<project_guidelines>` | +| I10 | **Compaction trigger only on input tokens, not output** | Triggers when `lastInputTokens > ContextBudget * 0.8` (`usechat.go:576-581`) | Forge tracks both, plus per-tool output spillover to temp files | Add output-token tracking. Long tool outputs (>20KB) should be spilled to a temp file in chat scratchspace and the model gets a stub + path | +| I11 | **Sub-agent step budget too low for complex tasks** | `SpawnTaskMaxSteps=15`, `SpawnTaskTimeout=120s` — too short for anything non-trivial | Forge `task` tool runs sub-tasks in **parallel** via `join_all`, no fixed step cap (config-driven) | Bump to ~30 steps + 600s default in `pkg/agent/tools/spawn_task.go`. Make sub-agents return their final assistant message text, not just metrics summary | +| I12 | **Sub-agent only returns summary** | Returns step/tool counts; caller has to parse | Forge's `task` returns the actual response from the sub-agent | Extract last assistant message from sub-chatstore before deletion in `pkg/agent/tools/spawn_task.go:113` | +| I13 | **Output truncation drops content** — model can't recover dropped content | `shell_exec` truncates at 8192 bytes (`shellExecTailBytes`); `web_fetch` truncates at 100KB; rest is gone | Forge spills overflow to temp files; model can re-read or `fs_search` them | When truncating, write full output to `<chatdir>/scratch/<tool>-<id>.out` and return path in response | +| I14 | **No "research" sub-mode** — `ask` is the only read-only mode but lacks the prompt discipline of Forge's Sage | Single ask prompt | Forge has `sage.md` — research-only with mandatory `Research Summary / Key Findings / Technical Details / Insights / Follow-ups` schema | Optional: add a fourth mode `research` for read-deep + cite. Lower priority — partial overlap with `ask` | + +### Nice-to-have — smaller wins, do later + +| # | Gap | Notes | +|---|---|---| +| N1 | Tool aliases like `Read`/`Write` | Forge ships `#[serde(alias = "Read")]` to match training priors; possibly +0.5pp | +| N2 | Reasoning effort levels per session | `:reasoning-effort none/minimal/low/medium/high/xhigh/max` in Forge; Crest has `ThinkingLevel` but no slash command | +| N3 | Skill lazy-loading | Forge keeps skill bodies out of the system prompt; only descriptions visible until `skill_fetch`. Crest has `pkg/agent/skills.go` — check if it's already lazy | +| N4 | Snapshot tests of rendered prompts | `crates/forge_app/src/orch_spec/snapshots/`. Add Go snapshot tests for assembled system prompt — guards against accidental prompt regressions | +| N5 | `followup` tool | Forge surfaces clarifying questions as a structured tool. Crest just lets the model write text. Useful for non-bench UX | +| N6 | Permission policy YAML | Forge `permissions.default.yaml`. Lets advanced users customize without recompiling | + +--- + +## Recommended implementation phases + +Each phase = one PR / commit batch. Stop after Phase 1 to validate baseline TB2 score; that number tells us how much the loop work matters vs the harness plumbing. + +### Phase 0 — Reproducibility setup (1-2 days) + +Goal: be able to run *some* TB2 score, even a bad one. +- Read Terminus-2 source (`harbor/src/harbor/agents/terminus_2/`) end-to-end. It's the canonical simple loop and the reference scaffold from the paper. +- Set up a private Harbor worktree with `uv` + Docker. +- Run `harbor run -d terminal-bench/terminal-bench-2 -a oracle` to confirm the harness works. +- Run `harbor run -d ... -a terminus_2 -m anthropic/claude-opus-4-7 -n 1 --task-name <small_task>` to confirm the model API works. + +**Deliverable:** a one-page run-recipe doc and a baseline score for `terminus_2 + opus-4-7` on a 5-task subset. + +### Phase 1 — Harness adapter + headless mode (Critical: C1, C4) (3-5 days) + +`pkg/agent/` change. Requires app restart but does NOT touch the desktop UI. + +- New `pkg/agent/harbor/` package containing: + - A stdin/stdout JSON-line driver (Crest spawns Go binary; Harbor adapter pipes prompt in, reads tool calls + final answer out) + - Headless tool-approval policy: auto-approve everything except destructive shell commands (and even then, since we're inside a TB Docker container, allow them) + - Mode `ModeBench` in `pkg/agent/modes.go` with `AllowMutation=true`, full tool palette, no approval gates +- Python adapter under `benchmarks/harbor-adapter/crest_agent.py`: + - Subclass `BaseInstalledAgent` + - In `setup()`: `docker exec` to install the Crest binary in the task container + - In `run()`: stream the instruction to Crest, capture the trace + - Implement `populate_context_post_run()` from chatstore output +- Drive the **existing** shell_exec / read / write tools (no new tools yet). + +**Verification:** `harbor run -d terminal-bench/terminal-bench-2 -a crest -m anthropic/claude-opus-4-7 -n 5` produces a score. Baseline expected ~50-60% (matching simple-scaffold + Opus published numbers). + +### Phase 2 — Persistent shell session (Critical: C2) (2-3 days) + +Single most consequential gap after the adapter. Long-running services (servers, daemons, watch-mode tests) currently break Crest's per-call shell. + +- New tool: `pty_session` (`pkg/agent/tools/pty_session.go`) + - `open(cmd?, cwd?)` → returns `session_id` + - `send(session_id, data, expect_output_for_ms?)` → returns new output + `is_running` + - `read(session_id, since_seq?)` → returns output and `is_running` + - `close(session_id)` +- Backed by a Go `*os.exec.Cmd` with a pty (use `creack/pty`) +- Outputs accumulate per session; per-call returns delta only +- Cap N=4 sessions per chat +- For benchmark mode, allow the model to use `pty_session` instead of `shell_exec` for anything that needs persistence + +**Verification:** add an eval task that runs `python -m http.server` in a session, then does `curl localhost:8000` in another, and confirms the model can do both. + +### Phase 3 — Search tools and multi-edit (Important: I1, I2, I3, I13) (2 days) + +Pure tool additions — no loop changes. All in `pkg/agent/tools/`: + +- `search` — wraps `rg --json`. Params: `pattern`, `glob?`, `multiline?`, `output_mode`. Read-only, auto-approve, parallel-safe. +- `glob` — simple glob match (or fold into `search` like Forge). +- `multi_edit` — atomic sequence of `(old_string, new_string, replace_all?)` operations on one file. Single backup, single approval. +- Output spillover for `shell_exec` and `web_fetch` — when truncating, write full output to `<chatdir>/scratch/<tool>-<id>.out` and return path. + +**Verification:** unit tests + one TB2 task that exercises grep + multi-file refactor. + +### Phase 4 — Loop discipline (Important: I4, I5, I6, I7, I8, I9) (4-6 days) + +Highest-leverage loop work. All in `pkg/aiusechat/usechat.go` and `pkg/agent/prompts/`. + +- **Doom-loop detection.** In the step loop, hash `(tool_name, args_json)` for last 6 calls. If 3+ identical, inject a system-message reminder. +- **Tool-error reflection.** When a tool returns an error, prepend the reflection template to the next tool result so the model sees it. +- **`todo_write` / `todo_read` tools** with pending-todos guard. Block "stop" responses if open items exist; inject reminder. +- **Compaction rewrite.** Replace `keepFirst=1, keepLast=10` with rendered summary. Implement template-based summary (one line per tool call: `**Update:** path`, `**Read:** path`, `**Search:** pattern`, `**Execute:** cmd`). Dedupe per-file-path to last operation. For Anthropic, copy last `reasoning_details` into the surviving assistant message. +- **Verification prompt.** Tighten `prompts/do.md` to require running the task's verifier before declaring done. Wire to todos: auto-add a "run verifier" todo after first mutation. +- **AGENTS.md auto-prepend.** In `pkg/agent/context.go` `BuildTerminalContext`: scan cwd for `AGENTS.md`, inject as `<project_guidelines>`. + +**Verification:** rerun the TB2 5-task subset; expect material improvement (10–15pp gain plausible). + +### Phase 5 — Sub-agent improvements (Important: I11, I12) (1 day) + +- Bump `SpawnTaskMaxSteps` to 30 and `SpawnTaskTimeout` to 600s. +- Return final assistant text from sub-agent, not just metrics. +- Update `spawn_task` description to recommend parallel use for independent sub-tasks. + +### Phase 6 — Full TB2 run + leaderboard (1 day) + +- Run the full 89-task suite with `-n 3` (3 attempts each, average pass@1). +- File a PR to `laude-institute/terminal-bench-leaderboard`. +- Iterate on individual task failures using Harbor's trace viewer. + +--- + +## Open questions / decisions to make + +1. **Headless mode trust model.** In benchmark mode, do we run *all* shell commands (including `rm -rf`) without approval, or do we keep dangerous-cmd guard active? I'd argue full pass-through inside Docker — the container is the sandbox. + +2. **Where the harness adapter lives.** Same repo, or sibling repo `crest-bench/`? Sibling avoids polluting Crest with Python + Docker dependencies. + +3. **Does `pty_session` replace `shell_exec`, or coexist?** Both probably needed — `shell_exec` for one-shot commands the user actually wants to see, `pty_session` for the agent's internal long-running processes. In the desktop UI, `shell_exec` is the only one with visible-block semantics. + +4. **Multi-agent split (Forge's muse/sage/forge)?** Likely overkill for Crest's UI use case; the existing `ask`/`plan`/`do` modes already cover this. Hold off. + +5. **Semantic search.** Forge has hosted `sem_search`. We don't, and shouldn't add a hosted dep. Could use a local embeddings index, but that's a multi-week project — out of scope for TB2 work. + +6. **Model choice for the leaderboard run.** `Claude Opus 4.7` is the obvious default. Worth comparing to `Sonnet 4.6` (faster, cheaper) and `GPT-5.4`. + +--- + +## Risks + +- **Anthropic rate limits.** A full 89-task × 3-attempt run with 30-min budgets per task can burn millions of tokens. Budget needed before kicking off the leaderboard run. +- **Harness instability.** Harbor is recently rewritten. Expect setup pain. Get the oracle agent working first as a smoke test. +- **Benchmarks lie.** Forge's 81.8% was inflated; Crest's clean number is likely the more interesting datapoint to publish. Don't ship a `crest-bench` `AGENTS.md` with answer keys — that's the cheating trap. +- **Loop changes can regress UI behavior.** The compaction rewrite (Phase 4) and todos guard touch hot paths used by the desktop UI. Keep snapshot tests + golden transcripts. + +--- + +## Sources + +- ForgeCode source survey: github.com/antinomyhq/forgecode (`crates/forge_domain/src/tools/`, `crates/forge_app/src/orch.rs`, `crates/forge_app/src/compact.rs`, `crates/forge_repo/src/agents/{forge,muse,sage}.md`, `templates/`) +- Terminal-Bench 2 mechanics: github.com/harbor-framework/terminal-bench-2, github.com/harbor-framework/harbor, harborframework.com/docs, arxiv.org/abs/2601.11868 +- Failure-mode breakdown: snorkel.ai/blog/terminal-bench-2-0-raising-the-bar-for-ai-agent-evaluation/, morphllm.com/terminal-bench-2 +- Cheating audit: debugml.github.io/cheating-agents/ +- Crest current state: this repo, branch `feat/native-agent`. Architecture at `docs/agent-architecture.md`. Tools at `pkg/agent/tools/`. Loop at `pkg/aiusechat/usechat.go:506-596`. Modes at `pkg/agent/modes.go`. Prompts at `pkg/agent/prompts/`. diff --git a/docs/warp-agent-analysis.md b/docs/warp-agent-analysis.md new file mode 100644 index 000000000..21ea18db3 --- /dev/null +++ b/docs/warp-agent-analysis.md @@ -0,0 +1,275 @@ +# Warp Agent → Crest: Source-Grounded Analysis + +Reference document for crest agent improvements derived from a reading of Warp's source tree. This is not a parity tracker — it is a one-time architectural read with a prioritized backlog of items judged worth porting. + +## Status + +- Generated: 2026-05-19 +- Warp tree analyzed: `/Users/mac/Documents/open-source/warp` +- Crest baseline: this repo, at the analysis date +- All Warp paths below are relative to the Warp tree root. Line numbers are anchors from a read on the generation date — treat them as starting points, not eternal truths. +- Three items from the initial recommendation set were dropped as low-ROI for crest's roadmap; they remain in Section 2's capability tables with **SKIP — dropped** verdicts so future readers can see the decision was deliberate, not an oversight. See Section 3 "Dropped from roadmap" for the list. + +--- + +## 1. Warp Agent Architecture Overview + +### Top-level shape + +Warp's agent is a **client-orchestrator + server-harness** split, not a self-contained loop. The Rust client owns terminal state, action dispatch, UI, and persistence; the *LLM loop itself* is delegated to a server-side service (`warp_multi_agent_api` / "Oz") with pluggable "harnesses" (Oz, Claude Code, Gemini, OpenCode, Codex). This is the most important architectural fact for a crest reader, and it is the source of both Warp's strengths (one client, many model loops) and weaknesses (you cannot run Warp's agent against your own API key without their service). + +- **Entry**: `app/src/ai/blocklist/input_model.rs:50-121` — `InputConfig { input_type, is_locked }` toggles Shell vs AI; `app/src/ai/blocklist/agent_view/controller.rs:42-193` — `AgentViewController` manages full-screen vs inline display; `AgentViewEntryOrigin` (`:102-192`) enumerates ~40 entry points (keybinding, palette, CLI, etc.). +- **Driver**: `app/src/ai/agent_sdk/driver.rs:664-756` (`AgentDriver::run`) → `:1232-1320` (`run_internal`) sets up terminal session, MCP servers, cloud env, then invokes the harness. +- **Harness boundary**: harness is a trait; the actual model streaming happens behind `warp_multi_agent_api`. The driver registers an event consumer at `:699-701` and lets the harness drive. +- **Event stream**: `app/src/ai/blocklist/orchestration_event_streamer.rs` and `â€Ļ/orchestration_events.rs:71-150` queue `AgentEvent` protos and route them to consumers; supports per-parent → per-child lifecycle subscriptions for multi-agent orchestration. +- **Action queue**: `app/src/ai/blocklist/action_model.rs:1-150` — `BlocklistAIActionModel` with `AIActionStatus` state machine (`Preprocessing → Queued → Blocked → RunningAsync → Finished`). Async **preprocessing** before queueing lets actions like `SearchCodebase` warm up while the user is still reading. +- **Conversation store**: `app/src/ai/blocklist/history_model.rs:185-200` — `BlocklistAIHistoryModel` keyed by `AIConversationId` (UUID). Persisted via `crates/persistence/` (Diesel + SQLite). +- **Orchestration config**: `crates/ai/src/agent/orchestration_config.rs:11-50` — `OrchestrationConfig { model_id, harness_type, execution_mode: Local | Remote { environment_id, worker_host } }` + `OrchestrationConfigStatus { None | Approved | Disapproved }`. `matches_active_config()` (`:64-96`) lets sub-agent spawns auto-launch if they do not change the approved config. + +### Tool/action system — the 28-variant enum + +`crates/ai/src/agent/action/mod.rs:32-177` defines `AIAgentActionType` with 28 variants. Each has a mirrored `AIAgentActionResultType` in `â€Ļ/action_result/mod.rs`. Key clusters: + +- **Shell**: `RequestCommandOutput { command, is_read_only: Option<bool>, is_risky: Option<bool>, wait_until_completion, uses_pager, rationale, citations }` (`:36-59`), `WriteToLongRunningShellCommand`, `ReadShellCommandOutput`, `TransferShellCommandControlToUser`. +- **Files**: `ReadFiles`, `RequestFileEdits { file_edits: Vec<FileEdit>, title }` (`:76-79`), `UploadArtifact`. +- **Search**: `SearchCodebase` (vector RAG), `Grep`, `FileGlob`/`FileGlobV2`. +- **Documents**: `ReadDocuments`, `EditDocuments`, `CreateDocuments` — Warp's first-class "AI document" concept (`crates/ai/src/document.rs`). +- **MCP**: `CallMCPTool`, `ReadMCPResource`. +- **Multi-agent**: `StartAgent` (`:148-154`), `SendMessageToAgent`, `RunAgents` (`:176`) with `RunAgentsRequest { summary, base_prompt, skills, model_id, harness_type, execution_mode, agent_run_configs }` (`:186-195`). +- **UX**: `AskUserQuestion { questions: Vec<AskUserQuestionItem> }` (`:167-169`), `SuggestNewConversation`, `SuggestPrompt`. +- **Computer use**: `UseComputer`, `RequestComputerUse` (live: `crates/computer_use/src/` — mac/linux/windows mouse/keyboard/screenshot, `lib.rs:76-106` enumerates `Action::MouseDown/Up/Move/Wheel/TypeText/KeyDown/KeyUp/Wait/Screenshot`). +- **Code review**: `InsertCodeReviewComments { repo_path, comments, base_branch }` (`:133-137`). +- **Skills**: `ReadSkill` (`:142`) — loads a skill MD at runtime. + +Conversion API ⇄ internal lives in `â€Ļ/action/convert.rs` (~600 LOC of `TryFrom` impls) and `â€Ļ/action_result/convert.rs`. Tool args are **not streamed** — the full call is reassembled before conversion. (This is a real limitation; see Section 4.) + +### Skills + +`crates/ai/src/skills/` — skills are **knowledge artifacts, not tools**. Each is a Markdown file with optional YAML front matter (`parser.rs:38-97`) located at `~/.{agents,warp,claude,codex,cursor,gemini,copilot,factory,github,opencode}/skills/{name}/SKILL.md` or in-repo at the same relative paths. Provider ranks: Agents > Warp > Claude > Codex > Cursor > Gemini > Copilot > Droid > GitHub > OpenCode (`skill_provider.rs:104-158`). Scope is `Home | Project | Bundled`. The `ReadSkill` action loads one into the prompt when the agent needs it. + +### Project/workspace context + +`crates/ai/src/project_context/model.rs:1-712`. `ProjectContextModel` is a singleton that scans for `WARP.md` / `AGENTS.md` files **up to 3 levels deep, max 5000 files** (`:20`), gitignore-aware via the `ignore` crate, and emits `KnownRulesChanged(RulesDelta)` events when files change. `find_applicable_rules(path)` returns ancestor-applicable rules → injected into the next prompt. + +### Codebase RAG + +`crates/ai/src/index/full_source_code_embedding/` is a real RAG pipeline: +- `manager.rs:166-196` — per-workspace `CodebaseIndex` with a `BulkFilesystemWatcher` (10 s debounce) and a `BuildQueue`. +- `chunker.rs` — semantic (language-aware) chunking; tree-sitter via `crates/syntax_tree/`. +- Merkle-tree snapshots every ~10 min for incremental sync. +- Multi-model embedding: OpenAI `text-embedding-3-small` (256d), Voyage Code 3 / 3.5 / 3.5 Lite (512d). +- Retrieval is **on-demand**, invoked when the agent emits `SearchCodebase` — *not* auto-stuffed into every prompt. + +### Streaming UI + +- **Network**: `app/src/ai/blocklist/controller/response_stream.rs:45-261` — `ResponseStream` spawns the SSE consumer, 3-retry backoff, yields `ResponseStreamEvent::ReceivedEvent`. +- **State**: `history_model.rs:2177-2203` — `UpdatedStreamingExchange` event per delta. +- **Render**: `app/src/ai/blocklist/block/view_impl.rs:1-97`, output rendering at `â€Ļ/view_impl/output.rs:219`. +- **Delta calc**: `crates/markdown_parser/src/lib.rs:64-107` — `compute_formatted_text_delta` produces `{ common_prefix_lines, old_suffix, new_suffix }` so the UI re-renders only changed lines (important for very long agent responses). +- **Inline previews**: `app/src/ai/blocklist/inline_action/requested_command.rs:1607-1620` (command suggestion chip), `â€Ļ/code_diff_view.rs:1-100` (`warp_editor::DiffViewer` for file edits). Accept: `ACCEPT_PROMPT_SUGGESTION_KEYBINDING` (Cmd+Enter / Ctrl+Shift+Enter). +- **Citations**: `crates/ai/src/agent/citation.rs:6-26` — `AIAgentCitation::{WarpDriveObject, WarpDocumentation, WebPage}` attached to `RequestCommandOutput`; rendered as clickable chips at `view_impl.rs:655-728`. + +### Safety / providers + +- **Permission gate**: `app/src/ai/blocklist/permissions.rs:850-950`. Denylist → allowlist → `AgentDecides` mode (allows non-risky read-only if feature flag enabled) → `AlwaysAsk`. Per-action `is_read_only`/`is_risky` flags come *from the LLM* — defense-in-depth, not authoritative. +- **Command signatures**: `crates/command-signatures-v2/` and `command-signatures-v2/js/` — these are for **completion/parse**, not risk classification. Do not confuse them. +- **API keys**: `crates/ai/src/api_keys.rs:19-34` — `ApiKeys { google, anthropic, openai, open_router }` stored in OS keyring under one JSON blob ("AiApiKeys"); `aws_credentials.rs:10-49` uses the AWS chain (not persisted). +- **Providers**: `llm_id.rs:3-11` — `LLMId` is just a string wrapper. No local model support found (no ollama/llama.cpp/candle in the agent path; `candle` is used only by `input_classifier`). +- **Telemetry**: `crates/ai/src/telemetry.rs:13-47` — Rudderstack events for index ops; gated by feature flags; UGC-marked events route separately. +- **Sandbox/isolation**: `crates/isolation_platform/src/lib.rs:31-45` — `Docker | DockerSandbox | Kubernetes | Namespace` for self-hosted deployments. Client itself does **no** filesystem chroot — isolation is deployment-level (containers, pods). + +### NLD ("is this NL vs shell?") + +`crates/input_classifier/` (the active path; `crates/natural_language_detection/` is the older word-list crate it depends on). Two tiers: heuristic (`heuristic_classifier/mod.rs`) + ONNX `bert_tiny.onnx` with dual backends (`onnx/ort.rs` and `onnx/candle.rs`). Word lists at `crates/natural_language_detection/{words.txt, stack_overflow.txt, stack_overflow_overlap_command.txt}`. Eval harness at `src/bin/evaluate.rs`. Invoked from `app/src/ai/blocklist/input_model.rs:617-749`. + +--- + +## 2. Capability-by-Capability Evaluation + +Legend: **PORT** = take directly, **ADAPT** = good idea, needs rework, **SKIP** = does not apply or crest already has it, **SKIP — dropped** = considered and removed from roadmap. + +### A. Agent loop & harness abstraction + +| Item | Verdict | Reason | +|---|---|---| +| Top-level driver pattern (`AgentDriver`) | **SKIP — at parity** | Crest has `pkg/agent/agent.go` + `pkg/aiusechat/usechat.go` with `RunAgent`/`RunAIChat` step loop. Same shape, simpler. | +| Server-side harness abstraction (Oz/Claude/Gemini/etc.) | **SKIP** | Warp-specific; depends on their proprietary MAA service. Crest's provider abstraction (`pkg/aiusechat/anthropic\|openai\|google\|gemini`) is the right shape for an OSS product. | +| Async **action preprocessing** before queueing (`BlocklistAIActionModel`) | **ADAPT** | Latency win for `SearchCodebase`-style heavy actions. Crest's `processToolCall` is mostly inline; adding a "preprocess" phase per-tool would help if/when crest adds RAG. | +| `OrchestrationConfig.matches_active_config()` for sub-agent auto-launch | **ADAPT** | Useful pattern if crest's `spawn_task` grows. Currently overkill. | +| Generation-counter idle-timeout (`driver.rs:149-204`) | **SKIP** | Clever in Rust async; Go's `context.WithTimeout` makes it unnecessary. | + +### B. Tool/action system + +| Item | Verdict | Reason | +|---|---|---| +| Per-action `is_read_only` + `is_risky` LLM-emitted flags | **SKIP — dropped** | Low ROI for crest's current roadmap. Regex-based `pkg/agent/tools/dangerous.go` stays as the primary safety gate. | +| `rationale: Option<String>` on every tool call | **SKIP — dropped** | Low ROI; users have other signals (diff preview, audit log) for trust. | +| **Long-running command tools**: `WriteToLongRunningShellCommand` + `ReadShellCommandOutput` + `TransferShellCommandControlToUser` | **PORT** | Crest's `shell_exec.go` runs to completion. Warp lets the agent kick off a server, watch logs, send Ctrl-C, **hand back to the user**. This is a meaningful capability gap. | +| `AskUserQuestion { questions: Vec<AskUserQuestionItem> }` as a first-class tool | **PORT** | Crest has approval cards but not "agent asks a structured multi-choice question." Cheap to add; clarifies a lot of conversations. | +| `RequestFileEdits { file_edits: Vec<FileEdit>, title }` — batched edits with a single title | **ADAPT** | Crest has `multi_edit.go`; check whether it groups edits into a single approve-card. Title field improves UI. | +| `SuggestNewConversation` / `SuggestPrompt` actions | **SKIP** | Warp-specific UX (sidebar-driven). Low value for crest. | +| `InsertCodeReviewComments` tool | **ADAPT later** | Only relevant when crest builds a code-review surface. | +| `RunAgents` / `StartAgent` / `SendMessageToAgent` multi-agent | **ADAPT** | Crest has `spawn_task.go` (sub-tasks?). Warp's structure (shared `base_prompt` + per-child `agent_run_configs`) is cleaner; worth studying when crest's spawn tool grows. | +| `UseComputer` (mouse/keyboard/screenshot) | **SKIP** | Out of scope for a terminal-first product; crest has `tools_screenshot.go` for the basic case. | +| Skill system (Markdown SKILL.md with YAML front matter, multi-vendor scopes) | **SKIP — dropped** | Cross-vendor discovery convention judged low ROI. Crest's `pkg/agent/skills.go` + `pkg/agent/prompts/` is sufficient. | + +### C. Prompt / context / session + +| Item | Verdict | Reason | +|---|---|---| +| **WARP.md / AGENTS.md ancestor scan** with gitignore + 3-deep / 5000-file cap | **SKIP — dropped** | Monorepo win, but crest's single-file `CLAUDE.md` read is sufficient for current scope. | +| **Codebase RAG**: incremental Merkle-tree embedding index, on-demand retrieval | **ADAPT** | Real lift. Warp's pipeline (`crates/ai/src/index/full_source_code_embedding/`) is non-trivial. Worth it if crest plans repo-aware tasks; skip if focus stays single-file. | +| File-mention `FileLocations` with line ranges + `expand_surrounding_context` + grouping | **PORT (later)** | `crates/ai/src/agent/file_locations.rs:1-147`. Lightweight, clean abstraction; replaces ad-hoc "stuff file path strings in prompts" with a typed struct that also formats for display. Useful once @-mentions land in cmdblock-input. | +| Typed `AIAgentCitation` enum + UI chips | **PORT** | `crates/ai/src/agent/citation.rs:6-26`. Cheap, gives users "why did the agent pick this command/doc." | +| **Conversation/task DAG persistence** (`agent_conversations` ⇄ `agent_tasks` ⇄ `messages`) | **ADAPT** | Crest's chatstore is conversation-flat. Warp's two-level (conversation has many tasks, task has many messages) becomes valuable when sub-agents enter — `parent_task_id` lets you restore a fanned-out tree. Premature today; revisit when `RunAgents` lands. | +| Server-side prompt assembly via MAA | **SKIP** | Closed-source dependency. Crest's client-side assembly (`pkg/aiusechat/usechat-prompts.go`) is the correct architectural choice for OSS. | + +### D. Streaming UI & previews + +| Item | Verdict | Reason | +|---|---|---| +| `compute_formatted_text_delta` (line-level diff, only re-render the changed suffix) | **PORT** | `crates/markdown_parser/src/lib.rs:64-107`. Crest's inline blocks likely re-render the whole content per token; a TS port of this delta is a clear perf win for long responses. | +| Dedicated `DiffViewer` for proposed file edits with accept/reject keybinding | **SKIP — at parity** | Crest has `TermAgentInlineDiff` via jsdiff (`docs/agent-architecture.md:179-203`). | +| Inline command-preview chip with citations + rationale | **ADAPT** | Crest already has command preview; add citation chips under it (rationale dropped above). | +| GFM-table parser as a separate step (`crates/ai/src/gfm_table.rs:62-107`) | **SKIP** | Markdown lib choice. Crest can pick a TS lib with GFM-table support out of the box. | +| Citation chips with icon-by-source + click handler | **PORT** | Pairs with the citation typing recommendation. | +| Voice input (`crates/voice_input/`) | **SKIP** | Not a priority; out-of-scope for the terminal. | +| `ResponseStream` 3-retry SSE consumer | **SKIP — at parity** | Crest has `httpretry.go` on the backend; SSE retry is a frontend concern not in Warp's source on the client. | + +### E. Safety / providers + +| Item | Verdict | Reason | +|---|---|---| +| Denylist > allowlist > AgentDecides > AlwaysAsk gate | **SKIP — at parity** | Crest has `pkg/agent/permissions/` already. The LLM-flagged tier (`is_risky`) was dropped above. | +| `OrchestrationConfigStatus::{Approved, Disapproved}` — once user approves a config, sub-agent invocations matching it auto-launch | **ADAPT** | Good UX for multi-agent flows. Defer until crest does that. | +| Multi-provider keys in OS keyring as single JSON blob | **SKIP — at parity** | Crest stores via `pkg/wconfig` / `pkg/secretstore`. | +| Sandbox/isolation (`crates/isolation_platform/`) | **SKIP** | Deployment concern; not a client-side library. | +| Telemetry trait + UGC flag separation | **ADAPT** | If crest grows telemetry, the `contains_ugc: bool` boundary on each event is a clean privacy pattern. | +| `command-signatures-v2` | **SKIP** | Completion-time data; not safety. Crest uses different completion tooling. | +| Read-only session mode (block all agent commands at the session level) | **PORT** | Cheap, very useful "I want the agent to look but not touch." Crest does not expose this cleanly today. | + +### F. NLD + +The dedicated comparison concludes **crest is at parity or ahead** on NLD: tier-1 + tier-2 with multilingual MiniLM, explicit thresholds, training pipeline, quantization tooling. Three genuine gaps: + +| Item | Verdict | +|---|---| +| Dual-backend tier-2 (ORT + Candle fallback) | **SKIP** — single ort-web path is fine for browser. | +| FastText alternate model | **SKIP** — multilingual MiniLM is better. | +| **Inline server-side telemetry** for classifier verdicts (Warp logs to backend, crest only `console.log`) | **PORT** when crest has a telemetry backend. | +| Online correction loop (collect user overrides → retrain) | **PORT — already scaffolded** (`training/finetune_classifier.py` + `corrections.jsonl` referenced in `frontend/app/term/nld/nld-model.ts:208`). Just needs the UI + persistence wiring. Neither Warp nor crest has it productionized. | + +--- + +## 3. Prioritized Recommendations for crest + +Four items, ordered by impact Ãˇ effort. Each names the Warp source pointer, the crest landing zone, an effort tag (S = days, M = ~week, L = ~weeks), and the risk that bites you. + +### Dropped from roadmap + +These were considered and excluded from the prioritized list as low ROI for crest's current direction: + +- Per-call `rationale` / `is_read_only` / `is_risky` flags on tool calls — regex denylist + existing approval cards are deemed sufficient. +- Ancestor `CREST.md` / `AGENTS.md` scanning — single-file `CLAUDE.md` read is enough at current repo scale. +- Cross-vendor `~/.<vendor>/skills/` discovery convention — crest's local skill/prompt loader is sufficient; cross-vendor interop is not a near-term goal. + +If future requirements change (heavy monorepo users, cross-tool skill sharing, model-declared safety becoming meaningfully better), revisit Section 2's PORT verdicts that pointed to these items. + +### 1. Long-running command tools (`write_long_running`, `read_long_running`, `transfer_to_user`) (M) + +- **Why**: Crest's `shell_exec` runs to completion. Real workflows ("start the dev server, tail the logs, hit Ctrl-C if it hangs, hand the terminal back") require the three-action set Warp has. This is the most user-facing capability gap. +- **Warp pointer**: `crates/ai/src/agent/action/mod.rs:61-65` (`WriteToLongRunningShellCommand`), `:126-129` (`ReadShellCommandOutput`), `:162-165` (`TransferShellCommandControlToUser`); the result variant `LongRunningCommandSnapshot` in `action_result/mod.rs`. +- **Crest landing zone**: + - `pkg/agent/tools/shell_exec.go` — extend with `wait_until_completion: bool`. When `false`, return a snapshot + block ID. + - New tools: `pkg/agent/tools/long_running_write.go`, `long_running_read.go`, `transfer_to_user.go`. + - PTY plumbing: reuse `pkg/jobmanager/` (already has `JobCmd`, streams). The block ID returned to the LLM is the existing `oid`. + - UI: `frontend/app/view/cmdblock/cmdblock-status.tsx` — render a "agent is watching this" badge + a "take over" button that wires `TransferShellCommandControlToUser`. +- **Effort**: **M**. +- **Risk**: PTY hand-off semantics (who owns the FD when control transfers). Test with `vim`, `top`, `python -i`, `npm run dev`. + +### 2. `ask_user_question` as a first-class tool (S) + +- **Why**: Reduces "agent guesses and then apologizes" loops. Multiple choice with optional free-form fallback. +- **Warp pointer**: `crates/ai/src/agent/action/mod.rs:167-169` + `AskUserQuestionItem`. +- **Crest landing zone**: + - `pkg/agent/tools/ask_user_question.go` (new). + - `pkg/aiusechat/uctypes/uctypes.go` — extend `UIMessageDataToolUse` if needed for the multiple-choice payload, or piggyback on existing approval card. + - `frontend/app/view/term/term-agent.tsx` — new `TermAgentAskCard` component (a focused button grid + optional input). + - Schema: each item has `question`, `header`, `options: [{label, description}]`, `multiSelect: bool`. +- **Effort**: **S**. +- **Risk**: None significant. Keep the UI keyboard-driven (1–9 to pick). + +### 3. Markdown-render delta computation (`compute_formatted_text_delta` port) (S–M) + +- **Why**: Streaming agent responses with code blocks re-render the entire body per token in many React setups. Warp computes a line-level common-prefix + new-suffix and only mutates the changed lines, which is meaningfully faster for long responses. +- **Warp pointer**: `crates/markdown_parser/src/lib.rs:64-107` (`compute_formatted_text_delta`). +- **Crest landing zone**: + - `frontend/app/view/term/term-agent.tsx` (or a new `term-agent-markdown.tsx`). + - Pure TS function; no backend change. + - Hook into the `TimelineEntry` rendering inside the inline-agent block timeline (`frontend/app/view/termblocks/termblocks.tsx`). +- **Effort**: **S–M**. +- **Risk**: React reconciliation can mask the savings if you are already keying lines correctly — profile first to confirm there is actually a problem. + +### 4. Typed `Citation` with chip rendering (S) + +- **Why**: When the agent runs a command derived from your docs/web search/internal note, the UI should show the source. Currently crest has nothing typed for this. +- **Warp pointer**: `crates/ai/src/agent/citation.rs:6-26` (`AIAgentCitation::{WarpDriveObject, WarpDocumentation, WebPage}`); render at `app/src/ai/blocklist/block/view_impl.rs:655-728`. +- **Crest landing zone**: + - `pkg/aiusechat/uctypes/uctypes.go` — add `Citation { kind: "web" | "doc" | "history"; url?: string; title: string }` to `ToolAuditEvent` or the message stream. + - `pkg/agent/tools/web_fetch.go`, `search.go`, `cmd_history.go` — populate citations. + - `frontend/app/view/term/term-agent.tsx` — new `TermAgentCitationChips` component (icon + truncated label, click → open URL/file). +- **Effort**: **S**. +- **Risk**: None. Easy iterative add. + +### Honorable mentions (just below the cutoff) + +- **Codebase RAG (`SearchCodebase` + Merkle-tree incremental index)** — high impact, but **L** effort (chunker, embedding provider integration, snapshot persistence). Defer until crest has a clear repo-aware-task feature driving it. +- **Conversation/task DAG persistence** — premature without multi-agent spawn. +- **Read-only session mode** — small but useful; promote to the prioritized list if a user-visible "lookbook" mode lands on the roadmap. + +--- + +## 4. Anti-patterns in Warp to *not* copy + +1. **Server-side prompt assembly via opaque MAA service.** Warp's client does not own the system prompt — it ships structured `InputContext` to `warp_multi_agent_api` and the server assembles the actual prompt. Convenient for them; opaque to users, and incompatible with an OSS / BYOK product. Crest's client-side `pkg/aiusechat/usechat-prompts.go` is correct; do not migrate to server-side. + +2. **The 28-variant `AIAgentActionType` enum.** Each tool added requires touching the enum, `convert.rs`, `action_result/mod.rs`, `action_result/convert.rs`, plus the UI's inline-action match. Crest's tool-registry pattern (`pkg/aiusechat/tools.go` + `pkg/aiusechat/tools_builder.go`) is the right shape; do not enumify. Add new tools as registry entries. + +3. **Harness sprawl (Oz/ClaudeCode/Gemini/OpenCode/Codex).** Five harness names, each a different proto branch (`orchestration_config.rs:189-214`). This is a marketing surface, not a technical abstraction — every harness ends up streaming the same thing. Crest's provider abstraction (one driver, many providers via `pkg/aiusechat/{anthropic,openai,gemini,google}`) avoids this. Keep it. + +4. **Non-streamed tool-call arguments.** Warp reassembles the full tool-call JSON before converting to action — long file edits sit in suspense. Crest's SSE pipeline already streams; do not lose that. + +5. **LLM-flagged `is_risky` treated as authoritative in `AgentDecides` mode.** Warp's docs and code rely on the model honestly classifying its own commands. The model can lie. Even though crest is not adopting `is_risky` (Section 3 "Dropped"), the meta-lesson stands: if a future change ever does, the regex denylist must stay as a non-overridable hard gate. + +6. **Computer-use cross-platform sprawl.** `crates/computer_use/src/{mac,linux,windows,linux/wayland,linux/x11}/{keyboard,mouse,screenshot}.rs` — five OS targets × three input types × Wayland-vs-X11 = a lot of code. crest is a terminal app; the value-per-line is low here. Skip. + +7. **`SuggestNewConversation` / `SuggestPrompt` as tools.** Warp uses these to drive sidebar UX. Building the underlying tools without the sidebar is dead code. Skip until/unless the UX exists. + +8. **"Skills" overload.** Warp treats skills as *both* runtime-loaded knowledge and a config surface that ranks across 10 vendor paths. The cross-vendor convention is interop bait; the ranking is a maintenance burden. (Already dropped from the roadmap above for the same reason.) + +9. **`OrchestrationStatus::Disapproved` round-tripping in protos when `None` would do.** A wart from API-versioning, not a pattern. + +--- + +## 5. Open questions + +These are points that could not be fully nailed down from the source alone and would need clarification before implementing: + +1. **`crates/natural_language_detection` vs `crates/input_classifier`** — both exist, both compile. They are related (NL detection word lists vs. the binary input classifier). Worth a 30-min dive before any crest NLD module unification — there may be a reason Warp keeps them split. + +2. **Codebase index storage shape** — `crates/ai/src/index/full_source_code_embedding/store_client.rs` and `sync_client.rs` were listed but not opened. Are embeddings stored locally (sqlite/vss?) or only server-side? Affects whether crest can replicate without a backend. + +3. **`AskUserQuestion` rendering** — the action type is mapped but not the rendering component path. Quickly grepping `app/src/ai/blocklist/inline_action/` for `AskUser` before building crest's `TermAgentAskCard` will save reinventing the UX. + +4. **Trajectory / audit-log format compatibility.** Crest writes `.crest-trajectories/<chatid>.json` (`docs/agent-architecture.md:114`). Warp ships `AgentEvent` proto records. If we ever want eval-harness interop, decide now whether crest's format should be a strict superset of Warp's or stay independent. + +--- + +## Summary + +Crest already has a thoughtfully built native agent (multi-provider, MCP, parallel tools, prompt caching, audit, inline blocks, diff preview, plan-to-do, model switcher, token counter). Warp's value-adds — relative to what crest has, after dropping the three low-ROI items — cluster in two buckets: + +- **More tool surface** (recommendation #1, #2 — long-running command lifecycle, `ask_user_question`). +- **Polish on streaming/provenance** (recommendation #3, #4 — markdown delta rendering, typed citations). + +Everything else (RAG index, multi-agent spawn, computer use, harness abstraction, server-side prompt assembly) is either premature, Warp-specific, or directly opposed to crest's OSS positioning. The four prioritized recommendations are **~3 S-weeks + 1 M-week** of work. diff --git a/docs/warp-agent-improvement-plan.md b/docs/warp-agent-improvement-plan.md new file mode 100644 index 000000000..6883bd6a1 --- /dev/null +++ b/docs/warp-agent-improvement-plan.md @@ -0,0 +1,604 @@ +# Crest Agent æ”šé€ čŽĄåˆ’ (åŸēäēŽ Warp 分析) + +æēæ–‡æĄŖīŧš`docs/warp-agent-analysis.md` +į”Ÿæˆæ—Ĩ期īŧš2026-05-19 + +äē”ä¸Ēé˜ļæŽĩįš„åŽžæ–Ŋ排期、子äģģåŠĄã€æ”ļį›Šã€éŖŽé™Šä¸Žénjæ”ļ标准。P0 是后插å…Ĩįš„åŸēįĄ€čŽžæ–Ŋ前įŊŽīŧˆč¯Ļč§ä¸‹æ–šč¯´æ˜Žīŧ‰ã€‚ + +## 排期é€ģ辑 + +äē”ä¸Ē phase 按"先åŸēįĄ€čŽžæ–Ŋ、再小后大、再äŊŽéŖŽé™ŠåŽéĢ˜éŖŽé™Š"排īŧš + +| Phase | äģģåŠĄ | Effort | 排åēį†į”ą | +|---|---|---|---| +| **P0** | Track C — Agent UI 在新åŧ•擎上重čŋž | M (~9d) | **前įŊŽåŸēįĄ€čŽžæ–Ŋ**。term-engine čŋį§ģ后 agent UI 不存在īŧŒP1.4 / P2 / P3 / P4 éƒŊ需čρ䏀ä¸ĒčƒŊæ¸˛æŸ“ tool-use card įš„åŽŋä¸ģ。č¯Ļ见 `docs/term-engine-migration.md` Track C。 | +| **P1** | Typed Citations | S | 最小īŧŒåģēįĢ‹ audit-log → SSE → UI įš„ pattern。后įģ­å‡ ä¸ĒäģģåŠĄéƒŊčρčĩ°čŋ™æĄįŽĄé“ã€‚**P1.4 厞æ”ļįŧ–到 P0.4**。 | +| **P2** | ask_user_question | S | å¤į”¨ P1 įš„ SSE æļˆæ¯åŊĸ态īŧˆå¤šäē†ä¸€ä¸Ē card įąģ型īŧ‰ã€‚ | +| **P3** | Markdown delta æ¸˛æŸ“ | S–M | įē¯ FE 攚动īŧŒčˇŸåŽį̝觪č€ĻīŧŒå¯äģĨ和 P4 åšļčĄŒã€‚åŋ…éĄģ先 profile įĄŽčŽ¤į“ļéĸˆå†åŠ¨æ‰‹ã€‚ | +| **P4** | é•ŋæ—ļ间å‘Ŋäģ¤åˇĨå…ˇįģ„ | M | 最大īŧŒæļ‰åŠ PTY 所有权į§ģäē¤ã€‚前éĸ P1/P2 įš„ UI įŽĄé“į†Ÿäē†īŧŒåščŋ™ä¸Ēįš„čžšé™…æˆæœŦäŊŽã€‚ | + +**æ€ģåˇĨ期äŧ°įŽ—**īŧšP0 ~9d + 3 ä¸Ē S 周 + 1 ä¸Ē M 周 ≈ **5 周单äēē**。P3 和 P4 可åšļ行 → 压įŧŠåˆ° ~4 周。 + +### čŋ›åēĻįŠļ态īŧˆ2026-05-19īŧ‰ + +- **P1.1 / P1.2 / P1.3 / P1.5 åˇ˛åŽŒæˆ** — Citation įąģ型 + 三åˇĨå…ˇæŽĨå…Ĩ + audit æ‹ˇč´ + Go 单æĩ‹å…¨éƒ¨ ship 到 backendīŧŒį­‰ P0 čŊ地后 UI į̝čƒŊį›´æŽĨæļˆč´šã€‚ +- **P1.4 厞æ”ļįŧ–到 P0.4** — citation chip æ¸˛æŸ“æ˜¯ ToolUseCard įš„ä¸€éƒ¨åˆ†īŧŒä¸å†å•į‹Ŧ成 phase。 +- **P0 垅启动** — č§ä¸‹æ–š `## P0` įĢ čŠ‚ã€‚ + +--- + +## P0 — Track C: Agent UI 在新åŧ•擎上重čŋžīŧˆ~10 夊īŧ‰ + +æēæ–‡æĄŖīŧš`docs/term-engine-migration.md` § Track C (P19)。 + +### čƒŒæ™¯ + +Term-engine migrationīŧˆP1–P16īŧ‰æŠŠæ—§įš„ `view/term/term-agent.tsx`īŧˆ810 LOCīŧ‰čŋžåŒ `TermAgentChatProvider`、`term-agent-tool-renderer.tsx` 一čĩˇåˆ äē†ã€‚æ–°åŧ•擎 (`frontend/app/term/`) 厌成äē† cell-grid + block æ¸˛æŸ“īŧŒäŊ† agent UI æ˛ĄæŽĨ回æĨ —— `frontend/app/term/render/terminal-view.tsx:511-516` 昞åŧæŗ¨æ˜Ž "Agent overlay is not wired in this engine revision"。 + +后į̝īŧˆ`pkg/agent/` + `pkg/aiusechat/`īŧ‰åŽŒå…¨ ready åšļ且 P1.1–P1.3 厞įģæŠŠ Citation 字æŽĩé“ē到äē† SSE 上īŧŒåĒæ˜¯æ˛Ąæœ‰ FE æļˆč´šč€…。**P0 觪冺čŋ™äģļäē‹**īŧŒP1.4 / P2 / P3 / P4 全部䞝čĩ–厃。 + +### čŽžčŽĄå†ŗį­–īŧˆåŧ€åˇĨ前 lockīŧ‰ + +1. **C1 vs C2 → 选 C2īŧˆunified blocklistīŧŒæŒ‰åˆ›åģēéĄēåē appendīŧ‰** + - C2 = `Block.kind = "agent"` 变äŊ“īŧŒä¸Ž shell block å…ąį”¨ä¸€æĄ blocklistīŧŒ**按创åģēæ—ļ厚äŊ**īŧˆéģ˜čޤ append 到æœĢå°žīŧ‰īŧŒåˆ›åģē䚋后äŊįŊŽä¸å†æ”šã€‚**不**做 timestamp 后排åēã€‚ + - Warp åŽžé™…æ¨Ąåž‹īŧšblocklist 是 `BlockList { blocks: Vec<Block>, removable_blocklist_item_positions: HashMap<RemovableBlocklistItem, TotalIndex> }` (`app/src/terminal/model/blocks.rs:234, 239-260`)。Agent block 通čŋ‡ `RemovableBlocklistItem::RichContent` 变äŊ“加å…ĨīŧŒåޚäŊ API 全是昞åŧé”šį‚š (`insert_rich_content_before_block_index` / `insert_rich_content_after_item` / `append_item_to_blocklist`īŧŒ`blocks.rs:3247, 3263, 1074`)。Agent äŧšč¯æœŦčēĢæ˜¯ `live_conversation_ids_for_terminal_view: Vec<AIConversationId>` įē¯ push (`history_model.rs:668, 843`)。`SumTree<BlockHeightItem>` åĒæœåŠĄ O(log N) viewport æŸĨč¯ĸīŧŒä¸å‚与排åēã€‚ + - `docs/agent-architecture.md` §12 䚟是 C2。Engine 攚动是įē¯åŠ æŗ•ã€‚ + +2. **输å…Ĩå…ĨåŖ → å¤į”¨ `cmdblock-input.tsx`** + - åˇ˛æœ‰įš„ 1595 LOC 输å…ĨæĄåˇ˛įģæœ‰ `mode: "terminal" | "agent" | "auto"` 三态、NLD 分įąģ器、`onSubmit(text, mode)` å›žč°ƒã€‚ä¸æ–°åģē overlay。`mode === "agent"` æ—ļ `onSubmit` čĩ° useChat 而非 PTY。 + +3. **useChat 攞å“Ē → `TerminalView` įģ„äģļåą‚** + - TerminalModel ä¸į›´æŽĨ持有 ai-sdk message å¯ščąĄīŧˆéŋ免 jotai/useChat 双 reactive 撞čŊĻīŧŒTrack C æ–‡æĄŖåˆ—įš„æœ€å¤§éŖŽé™Šīŧ‰ã€‚TerminalModel åĒæŒį¨ŗåŽšåŧ•ᔍ atomsīŧŒuseChat įš„ message stream 通čŋ‡ `useEffect` 同æ­Ĩ到 block。 + +4. **Port strategyīŧˆäžæŽį”¨æˆˇį­” B / B / Aīŧ‰**īŧš + - **Q1=B (minimum subset)**īŧšv1 port 6 ä¸Ēæ ¸åŋƒ warp æēæ–‡äģļīŧŒå¤š agent / orchestration / secret_redaction / RAG åĄį‰‡į•™ v2。 + - **Q2=B (crest TS å‘Ŋ名)**īŧšäŋį•™ warp č¯­äš‰äŊ†į”¨ crest kebab-case TS å‘Ŋ名。 + - **Q3=A (tight behavior port)**īŧšįģ„äģļåˆ†č§Ŗ / state shape / props / įŠļ态æœē / keybinding 1:1 莟 warpīŧ›**č§†č§‰å¸¸é‡**īŧˆéĸœč‰˛ã€é—´čˇīŧ‰į”¨ crest Tailwind token 而非 warp `pathfinder_color::ColorU` 字éĸå€ŧīŧˆäŋæŒä¸Ž crest å…ļäģ– UI 视觉一致īŧ‰ã€‚ + +5. **v1 čŒƒå›´čŖå‰Ēīŧˆæ˜žåŧä¸åšīŧ‰** + - ❌ Voice input wiringīŧˆ`onVoiceInput` prop į•™ stubīŧ‰ + - ❌ Fast-forward toggle 后į̝īŧˆUI į•™ disabled toggleīŧ‰ + - ❌ Suggestions row 数捎æŽĨå…Ĩ + - ❌ Model picker 厞际切æĸīŧˆdisabled dropdownīŧ‰ + - ❌ 表 block 选中 / 复åˆļ agent 文æœŦ + - ❌ Multi-agent orchestration UI (`run_agents_card_view.rs`, `orchestration_pill_bar.rs`, `child_agent_status_card.rs`) + - ❌ Secret redaction render (`secret_redaction.rs`) — backend 暂æœĒ挂čŋ™ä¸ĒīŧŒį•™ v2 + +6. **P1.4 与 P0.4 合åšļ** — citation chip 不åĻįĢ‹ phase。 + +### čŽ¸å¯č¯ & Attribution + +Warp source 在 `/Users/mac/Documents/open-source/warp` 是 MIT (Š 2020-2026 Denver Technologies, Inc.)。Port åˆč§„čĻæą‚īŧš + +1. **每ä¸Ē derived TS 文äģļéĄļ部加 attribution header**īŧš + ```ts + // Copyright 2026, Crest contributors. SPDX-License-Identifier: Apache-2.0 + // + // <Component name> — structure derived from warp/<source path>. + // Warp is Š 2020-2026 Denver Technologies, Inc., MIT licensed. + ``` +2. **crest repo æ šį›ŽåŊ•加 `NOTICES.md`**īŧšč´´ warp įš„ LICENSE-MIT 全文 + 列å‡ēčĄį”Ÿæ–‡äģᅬ…单。čŋ™æ˜¯ MIT "include the permission notice in substantial portions" įš„åˆč§„åšæŗ•ã€‚ +3. **Port 限äēŽį쓿ž„/state/UX**ã€‚ä¸į›´æŽĨæŦčŋ warp æēäģŖį åˆ° crest 文äģļ里 — æ˜¯į”¨ TS/React/Tailwind į­‰äģˇåŽžįŽ° warp 描čŋ°įš„čĄŒä¸ē。 + +### 排期īŧˆv1: 6 ä¸Ē warp æēæ–‡äģļ → 7 ä¸Ē phasesīŧ‰ + +| Phase | Effort | Warp source | Crest target | +|---|---|---|---| +| **P0.1** Engine `Block.kind` + AgentBlock payload | S (~1d) | (engine åą‚īŧŒæ— į›´æŽĨ UI 寚åē”) | `term/engine/block.ts`, `engine/types.ts`, `engine/blocks.ts` | +| **P0.2** TerminalModel agent atoms | S (~1d) | `app/src/ai/blocklist/history_model.rs:185-200` (`BlocklistAIHistoryModel`) | `term/terminal-model.ts` | +| **P0.3** Agent block element + header | S–M (~1.5d) | `agent_view/agent_view_block.rs` + `inline_agent_view_header.rs` | `term/render/agent-block-element.tsx` | +| **P0.4** Tool action cards (4 子įģ„äģļ) | M (~3.5d) | `inline_action/inline_action_header.rs` + `requested_command.rs` + `code_diff_view.rs` + `requested_command_attribution.rs` + `block/view_impl.rs:655-728` | `term/render/tool-action-header.tsx`, `tool-command-card.tsx`, `tool-diff-card.tsx`, `citation-chips.tsx` | +| **P0.5** useChat host + SSE bridge | S–M (~1.5d) | `controller/response_stream.rs:45-117` (į쓿ž„å‚č€ƒ) | `term/render/terminal-view.tsx` (host); æ–°æ–šæŗ•åœ¨ `terminal-model.ts` | +| **P0.6** cmdblock-input mode=agent čˇ¯į”ą | S (~0.5d) | `agent_view/agent_message_bar.rs` (crest åˇ˛æœ‰æ›´åŽŒæ•´įš„ cmdblock-input.tsx) | `cmdblock-input.tsx` (parent wiring) | +| **P0.7** Smoke + citation jump + polish | S (~1d) | `block/view_impl.rs:655-728` (citation click 行ä¸ē) | — | + +--- + +### P0.1 — Engine: Block.kind + AgentBlock payloadīŧˆS, ~1dīŧ‰ + +- **Warp 寚åē”**īŧšengine åą‚æ— į›´æŽĨ UI 寚å甿–‡äģļã€‚č¯­äš‰ä¸Š mirror warp įš„ `Block::AgentResponse` enum 变äŊ“īŧˆwarp `agent_view_block.rs` æŗ¨å†Œ block kind įš„æ–šåŧīŧ‰ã€‚ +- **Crest target**īŧš`frontend/app/term/engine/block.ts`、`engine/types.ts`、`engine/blocks.ts`。 +- **Port į­–į•Ĩ**īŧšįē¯åŠ æŗ•īŧŒshell block čˇ¯åž„é›ļ变更。AgentPayload 字æŽĩ名æ˛ŋᔍ warp `AIAgentOutput` č¯­äš‰īŧˆexchangeId、status、createdAtīŧ‰ã€‚ + +#### 子äģģåŠĄ + +1. `frontend/app/term/engine/block.ts`īŧšåŠ  `kind: "shell" | "agent"` 字æŽĩīŧŒéģ˜čޤ `"shell"`īŧŒæž„造æ—ļ可指厚。 +2. `engine/types.ts`īŧšåŠ  `AgentPayload` įąģ型 `{ exchangeId, userText, status: "streaming"|"done"|"error", createdAt }`。AgentBlock 通čŋ‡æ–°å­—æŽĩ `agentPayload?: AgentPayload` æēå¸Ļ。`createdAt` **äģ…äŊœä¸ē UI metadata**īŧˆåĻ‚ "5s ago" į›¸å¯šæ—ļ间昞į¤ēīŧ‰īŧŒ**不参与 block 排åē**。 +3. `Block.appendAgentText(delta)` / `setAgentStatus()` æ–šæŗ•īŧŒį왿¸˛æŸ“åą‚ mutate į”¨ã€‚Bump revision via įŽ°æœ‰ `markDirty()`。 +4. `engine/blocks.ts`īŧš`appendAgentBlock(exchangeId, userText)` åˇĨ厂īŧŒ**įē¯ append 到æœĢå°ž**īŧŒä¸åšäģģäŊ•按 timestamp įš„äŊįŊŽæŸĨæ‰žã€‚č¯­äš‰ mirror warp įš„ `BlockList::append_item_to_blocklist` (`blocks.rs:1074`) + `live_conversation_ids_for_terminal_view.push` (`history_model.rs:668`)。äŊįŊŽä¸€æ—ĻåŽšä¸‹å°ąä¸å†æ”šã€‚ +5. **Engine ä¸č§Ŗæž ANSI** — agent block 莺čŋ‡ AnsiParser。BlockHandler įœ‹åˆ° `block.kind === "agent"` æ—ļ no-opīŧˆé˜˛åžĄæ€§īŧ‰ã€‚ + +#### æ”ļį›Š + +- Agent æļˆæ¯æ­Ŗåŧčŋ›å…Ĩ timelineīŧŒä¸Ž shell block **按创åģēéĄēåē append**īŧˆæ—  post-hoc 重排īŧ‰â€”— warp åŽžé™…æ¨Ąåž‹å¯šéŊã€‚ +- įģ™åŽįģ­ P2 (`ask_user_question`) 和 P4 (long-running å‘Ŋäģ¤įš„ agent æ—æŗ¨) 提䞛įģŸä¸€åŽšå™¨ã€‚ +- Engine 攚动是įē¯åŠ æŗ•īŧŒshell block čˇ¯åž„é›ļ变更īŧŒå›žåŊ’éŖŽé™ŠæŽĨčŋ‘é›ļ。 + +#### éŖŽé™Š + +- Block ID å‘Ŋåå†˛įǁīŧšagent block 也čĩ° `BlockId` įąģ型。Mitigationīŧšį”¨ UUIDīŧˆå‰įŧ€ `agent_`īŧ‰äžŋäēŽæ—Ĩåŋ—åŒē分。 +- FindīŧˆCmd+Fīŧ‰į›Žå‰åĒį´ĸåŧ• shell block。v1 莊 agent block 䚟参与īŧˆ`Block.text()` æ‹ŧ `agentPayload.userText` + agent responseīŧ‰īŧŒ~10 LOC。 + +#### énjæ”ļ + +- 单æĩ‹īŧš`appendAgentBlock` append 到æœĢå°žīŧŒä¸åŊąå“åˇ˛æœ‰ block äŊįŊŽã€‚ +- 单æĩ‹īŧščŋžįģ­æˇˇåˆ append (shell, agent, shell, agent) 后īŧŒéĄēåēä¸Ĩæ ŧ = č°ƒį”¨éĄēåēã€‚ +- 单æĩ‹īŧš`Block.kind === "agent"` æ—ļ `AnsiParser.feed()` 不äŋŽæ”š grid。 + +--- + +### P0.2 — TerminalModel agent atomsīŧˆS, ~1dīŧ‰ + +- **Warp 寚åē”**īŧš`app/src/ai/blocklist/history_model.rs:185-200` (`BlocklistAIHistoryModel`)。Atom å‘ŊåčˇŸ warp field 寚éŊīŧš + - `agentChatStatusAtom` ↔ `AIAgentOutput.status` + - `agentChatIdAtom` ↔ `AIConversationId` (history_model.rs:42) + - `agentModelOverrideAtom` ↔ `OrchestrationConfig.model_id` (`orchestration_config.rs:12`) + - `agentPostureAtom` 是 crest į‰šæœ‰īŧˆwarp ᔍ BlocklistAIPermissionsīŧ‰ +- **Crest target**īŧš`frontend/app/term/terminal-model.ts`。 +- **Port į­–į•Ĩ**īŧšæ–šæŗ•į­žåčˇŸ warp event handler 寚éŊã€‚`applyAgentDelta` į­‰äģˇ warp `UpdatedStreamingExchange` event (`history_model.rs:2177-2203`) įš„ handler。 + +#### 子äģģåŠĄ + +1. `frontend/app/term/terminal-model.ts` 加 atomsīŧš + ```ts + agentVisibleAtom: PrimitiveAtom<boolean> + agentPostureAtom: PrimitiveAtom<string> // "default" | "strict" | "bench" + agentChatStatusAtom: PrimitiveAtom<"idle" | "streaming" | "error"> + agentChatIdAtom: PrimitiveAtom<string> + agentModelOverrideAtom: PrimitiveAtom<string | null> + ``` +2. æ–šæŗ• `submitAgentMessage(text)`īŧšį”Ÿæˆ exchangeId、`blocks.appendAgentBlock()`、把 exchangeId æš´éœ˛įģ™ useChat äŊœ `id`。 +3. æ–šæŗ• `applyAgentDelta(exchangeId, delta)` / `applyAgentStatus(exchangeId, status)`īŧšuseChat įš„ onChunk 调čŋ™ä¸¤ä¸Ē把 message 同æ­Ĩ到 block。 +4. æ–šæŗ• `getRecentCommands(n)`īŧšäģŽ `commandHistoryAtom` 拉īŧŒįģ™ agent system prompt į”¨ã€‚ + +#### æ”ļį›Š + +- TerminalModel 是 agent įŠļæ€įš„å”¯ä¸€įœŸæēīŧˆé™¤ useChat įš„ transient message bufferīŧ‰ã€‚ +- 后įģ­ P2 į›´æŽĨå¤į”¨ agentVisibleAtom + 新åĸž askCardAtomīŧŒæ¨Ąåŧä¸€č‡´ã€‚ +- Trajectory 复原变可čƒŊīŧšį”¨ `agentChatIdAtom` äģŽ chatstore æ‹‰åŽ†å˛ block 回攞čŋ› timeline。 + +#### éŖŽé™Š + +- 莟 cmdblock-input.tsx įš„ NLDModel.modeAtom 重复įŠļ态。Mitigationīŧšcmdblock-input ᔍ NLDModel å†ŗåŽščˇ¯į”ąīŧŒTerminalModel 莟č¸Ē"厞选 agent"äš‹åŽįš„äē‹ã€‚分åˇĨæ˜ŽįĄŽã€‚ + +#### énjæ”ļ + +- 单æĩ‹īŧš`submitAgentMessage` 创åģē一ä¸Ē agent block + čŋ”å›žį¨ŗåޚ exchangeId。 +- 单æĩ‹īŧš`applyAgentDelta` į´¯åŠ æ–‡æœŦīŧˆä¸æ˜¯čφᛖīŧ‰ã€‚ + +--- + +### P0.3 — Agent block element + headerīŧˆS–M, ~1.5dīŧ‰ + +- **Warp 寚åē”**īŧš + - `app/src/ai/blocklist/agent_view/agent_view_block.rs` — ä¸ģč§†å›žīŧˆstatus icon + title + bodyīŧ‰ + - `app/src/ai/blocklist/agent_view/inline_agent_view_header.rs` — block header sub +- **Crest target**īŧš`frontend/app/term/render/agent-block-element.tsx` (header 内联ä¸ēåą€éƒ¨įģ„äģļ)。 +- **Attribution header**īŧˆæ–‡äģļéĄļ部īŧ‰īŧš + ```ts + // Copyright 2026, Crest contributors. SPDX-License-Identifier: Apache-2.0 + // + // AgentBlockElement — structure derived from warp: + // app/src/ai/blocklist/agent_view/agent_view_block.rs + // app/src/ai/blocklist/agent_view/inline_agent_view_header.rs + // Warp is Š 2020-2026 Denver Technologies, Inc., MIT licensed. + ``` +- **Port į­–į•Ĩ**īŧš + - įģ„äģļåą‚įē§čˇŸ warp 一致īŧˆä¸ģ view → header sub + bodyīŧ‰ + - Status įŠļ态æœē 1:1 莟 warpīŧšInProgress / Success / Error / Blocked + - Status icon 4 į§éĸœč‰˛č¯­äš‰čˇŸ warpīŧŒäŊ†č‰˛å€ŧᔍ crest Tailwind 调色æŋīŧˆåĻ‚ `text-rose-400` 而非 warp `pathfinder_color` 字éĸå€ŧīŧ‰ + - Markdownīŧšį”¨ `react-markdown` + `remark-gfm`īŧˆwarp ᔍ `crates/markdown_parser`īŧŒTS į­‰äģˇį‰Šīŧ‰ + +#### 子äģģåŠĄ + +1. 新文äģļ `frontend/app/term/render/agent-block-element.tsx`。 +2. į쓿ž„īŧšį”¨æˆˇæļˆæ¯ chipīŧˆåŗå¯šéŊæˇĄčƒŒæ™¯īŧ‰+ 分隔įēŋ + agent response (markdown via `react-markdown` + remark-gfm) + status 指į¤ēīŧˆstreaming æ—ļå°žéƒ¨å…‰æ ‡į‚šīŧŒerror įēĸ色īŧ‰ã€‚ +3. `BlockListElement.tsx` dispatchīŧš`block.kind === "agent" ? <AgentBlockElement> : <BlockElement>`。 +4. æ ˇåŧīŧšå¤į”¨ Tailwind + įŽ°æœ‰ cmdblock 色æŋīŧŒä¸åŧ•æ–° scss。 +5. äģŖį å—č¯­æŗ•é̘äēŽīŧšv1 ᔍ react-markdown åŽŸį”Ÿ pre/codeīŧˆæ—  highlightīŧ‰ã€‚Shiki/Prism į•™ P3 markdown delta é˜ļæŽĩ一čĩˇå¤„į†ã€‚ + +#### æ”ļį›Š + +- į”¨æˆˇčƒŊįœ‹åˆ° agent å›žį­” —— 最 visible įš„čŋ›åą•。 +- įģ™ P0.4 ToolUseCard 提䞛åŽŋä¸ģ厚器īŧˆåĩŒåœ¨ markdown stream 里īŧ‰ã€‚ +- markdown å°ąäŊåŽīŧŒäģŖį å—ã€čĄ¨æ ŧã€åˆ—čĄ¨įĢ‹åˆģ work。 + +#### éŖŽé™Š + +- é•ŋ agent å›žį­”įš„ re-render 性čƒŊ —— æ­Ŗæ˜¯ P3 čĻč§Ŗå†ŗįš„ã€‚v1 å…ˆå…¨é‡é‡æ¸˛æŸ“īŧŒprofile æ•°æŽį”¨æĨ支撑 P3 å†ŗį­–ã€‚ +- į”¨æˆˇæļˆæ¯å’Œ agent response 间距 / č§†č§‰åą‚æŦĄ —— éĸ„į•™ 0.5 夊 polish。 + +#### énjæ”ļ + +- č§†č§‰å†’įƒŸīŧš"hello" įœ‹åˆ° user msg + agent reply éƒŊæ¸˛æŸ“ã€‚ +- č§†č§‰å†’įƒŸīŧš"show me a code block" įœ‹åˆ° ``` fence æ­ŖįĄŽæ¸˛æŸ“ã€‚ +- profileīŧš500 行æĩåŧ markdownīŧŒčް commit æ—ļ间äŊœä¸ē P3 baseline。 + +--- + +### P0.4 — Tool action cards (4 子įģ„äģļ)īŧˆM, ~3.5dīŧ‰ + +- **Warp 寚åē” â†’ Crest target**īŧˆ1:1 文äģ￘ å°„īŧ‰īŧš + | Warp source | Crest target | LOC est | + |---|---|---| + | `inline_action/inline_action_header.rs` | `term/render/tool-action-header.tsx` | ~50 | + | `inline_action/requested_command.rs` | `term/render/tool-command-card.tsx` | ~150 | + | `inline_action/code_diff_view.rs` | `term/render/tool-diff-card.tsx` | ~150 | + | `inline_action/requested_command_attribution.rs` + `block/view_impl.rs:655-728` | `term/render/citation-chips.tsx` | ~80 | +- **Attribution header**īŧˆæ¯ä¸Ē derived 文äģļīŧ‰īŧšå‚č€ƒ P0.3 æ¨ĄæŋīŧŒæ›ŋæĸ `<source path>` čĄŒã€‚ +- **Port į­–į•Ĩ**īŧš + - 文äģļæ‹†åˆ† = warp 文äģļæ‹†åˆ†ã€‚不合åšļ、不įģ†åˆ†ã€‚ + - Props shape = `UIMessageDataToolUse`īŧˆaitypes.ts åˇ˛æœ‰īŧ‰ã€‚ + - įŠļ态æœē pending → needs-approval → completed/error 莟 warp ToolUseCard ä¸€č‡´ã€‚ + - Approval keybindingīŧšCmd+Enter accept / Esc rejectīŧŒčˇŸ warp `ACCEPT_PROMPT_SUGGESTION_KEYBINDING`īŧˆ`view_impl.rs:78`īŧ‰ã€‚ + - Citation chip č§„åˆ™īŧˆicon-by-kind, ≤30 字įŦĻæˆĒ断, click → openīŧ‰īŧščˇŸ `view_impl.rs:655-728` ä¸€č‡´ã€‚ + - č§†č§‰å¸¸é‡īŧšcrest Tailwind tokens。 + +#### 子äģģåŠĄ + +1. 新文äģļ `frontend/app/term/render/tool-use-card.tsx`。Props æļˆč´š SSE `data-tooluse` å‡ēæĨįš„ `UIMessageDataToolUse`īŧˆaitypes.ts 镜像īŧ‰ã€‚ +2. įŠļæ€å¸ƒåą€īŧš + - **pending**: tool 名 + desc + čŊŦ圈 + - **needs-approval**: approve/deny 按钎 + Suggestions radioīŧˆ"remember this"īŧ‰ + - **completed**: tool 名 + desc + "see output" 折叠åŒē + - **error**: tool 名 + įēĸ色 error 文æœŦ +3. **Diff preview**īŧˆrestore from `docs/agent-architecture.md:179-203`īŧ‰īŧš`originalcontent` + `modifiedcontent` éƒŊ存在æ—ļīŧŒjsdiff æ¸˛æŸ“ unified diffīŧˆ3 行 contextīŧ‰ã€‚npm åˇ˛æœ‰ jsdiff。 +4. **Citation chipsīŧˆP1.4īŧ‰**īŧšcard åē•éƒ¨æ¸˛æŸ“ `citations[]`īŧš + - icon: web→`globe`, file→`file`, history→`clock`, doc→`book`īŧˆ`@/app/element/ui-icon`īŧŒįŧēįš„čĄĨ SVGīŧ‰ + - title æˆĒ断 ≤30 字įŦĻ + - click: web/doc → `getApi().openExternal(url)`īŧ›file → čˇŗåˆ°å¯šåē” block æģšåˆ° `LineStart`īŧˆP0.7 åŽžįŽ°īŧ‰īŧ›history → 复åˆļ到å‰Ēč´´æŋ +5. Approval 提äē¤čĩ° wshrpc `UpdateToolApproval(toolCallId, approval, content)`。后į̝ `pkg/aiusechat/toolapproval.go` åˇ˛æ”¯æŒã€‚ +6. åĩŒå…ĨäŊįŊŽīŧšäŊœä¸ē AgentBlockElement įš„ message-parts 数įģ„é‡Œįš„ inline block —— text part čĩ° markdownīŧŒtool-use part čĩ° ToolUseCard。 + +#### æ”ļį›Š + +- **P1.4 在此厌成 —— citation chip æ¸˛æŸ“å‡ēæĨ**。 +- į”¨æˆˇįœ‹åˆ°åˇĨå…ˇč°ƒį”¨ + diff + approval —— 整ä¸Ē agent flow é—­įŽ¯ã€‚ +- å¤į”¨ `Suggestions` 字æŽĩ → permissions engine įš„ "remember this" UX į›´æŽĨå¯į”¨ã€‚ +- įģ™ P2 (`ask_user_question`) 一ä¸Ēå‚č€ƒåŽžįŽ° —— åŒæ ˇįš„ card patternīŧŒæĸ payload。 + +#### éŖŽé™Š + +- **最复杂 phase**。半 day buffer éĸ„į•™ polish + diff edge caseīŧˆnew file、empty diff、binaryīŧ‰ã€‚ +- ai-sdk message part éĄēåēīŧštext 和 tool-use 怎䚈äē¤é”™ã€‚Mitigationīŧšæ¸˛æŸ“ `WaveUIMessage.parts` 数į섿Œ‰éĄēåē mapīŧŒä¸é‡æŽ’。 +- Diff 在 needs-approval æ—ļ昞į¤ēæ›´æœ‰į”¨īŧˆį”¨æˆˇå†ŗåŽšæ‰šå‡†å‰įœ‹å˜æ›´īŧ‰īŧ›åŠĄåŋ…įĄŽäŋ diff 数捎在 approval card 上å‡ēįŽ°īŧŒä¸æ˜¯åĒ在 completed æ—ļ。 + +#### énjæ”ļ + +- `read_file` → pending → completedīŧŒæ—  approval。 +- `write_file` → needs-approval → diff visible → approve → completed。 +- `web_fetch` → 1 ä¸Ē web citation chipīŧŒclick 打åŧ€æĩč§ˆå™¨ã€‚ +- `search` → ≤10 ä¸Ē file citation chipsīŧŒclick 暂æ—ļ console.log path:line。 +- `cmd_history` → history citation chips。 +- Trajectory 文äģļ包åĢ citations 字æŽĩīŧˆP1 厞énjčŋ‡īŧ‰ã€‚ + +--- + +### P0.5 — useChat host + SSE 同æ­ĨīŧˆS–M, ~1.5dīŧ‰ + +- **Warp 寚åē”**īŧšį쓿ž„å‚č€ƒ `app/src/ai/blocklist/controller/response_stream.rs:45-117`īŧˆ`ResponseStream` async taskīŧ‰+ `history_model.rs:2177-2203`īŧˆ`UpdatedStreamingExchange` eventīŧ‰ã€‚ +- **Crest target**īŧš`frontend/app/term/render/terminal-view.tsx`īŧˆuseChat hostīŧ‰īŧŒ`terminal-model.ts`īŧˆapplyAgentDelta etc.īŧ‰ã€‚ +- **Port į­–į•Ĩ**īŧšį쓿ž„ reference only。React ᔍ `@ai-sdk/react`'s `useChat` hookīŧŒwarp 是 Rust async taskīŧŒåŽžįŽ°åŽŒå…¨ä¸åŒã€‚äŊ† mirror čŋ™äē›īŧš + - 3-retry backoff (warp:145 `MAX_RETRIES=3`) + - 每 chunk → `applyAgentDelta`īŧˆį­‰äģˇ `UpdatedStreamingExchange`īŧ‰ + - `Finished` event → status=done + - `ClientActions` → tool-use card + +#### 子äģģåŠĄ + +1. `TerminalView.tsx` éĄļåą‚åŠ  useChat hookīŧˆ`@ai-sdk/react`īŧ‰īŧŒendpoint `/api/post-agent-message`īŧˆ`pkg/agent/http.go` åˇ˛æœ‰īŧ‰ã€‚ +2. message stream 同æ­Ĩīŧš + ```tsx + const { messages, sendMessage, status } = useChat({ id: chatId, api: ... }); + useEffect(() => { + const exchangeId = messages[messages.length - 1]?.id; + model.applyAgentDelta(exchangeId, lastDelta); + }, [messages]); + ``` +3. `agentChatStatusAtom` ← useChat.status (mapped: streaming/idle/error)。 +4. `data-tooluse` part čˇ¯į”ąīŧšuseChat åŊ“ message part 透å‡ēīŧŒAgentBlockElement æ¸˛æŸ“æ—ļ调 ToolUseCard。 +5. Posture / cwd / connection / lastCommand 上下文īŧšäģŽ TerminalModel 拉 → useChat `body` 字æŽĩ每æŦĄč¯ˇæą‚å¸Ļ上īŧˆHTTP handler åˇ˛čŽ¤čŋ™äē›å­—æŽĩīŧŒč§ `agent/http.go` PostAgentMessageRequestīŧ‰ã€‚ + +#### æ”ļį›Š + +- 整ä¸Ē agent é“žčˇ¯é€šīŧšį”¨æˆˇčž“å…Ĩ → POST → SSE æĩå›ž → block æ¸˛æŸ“ → tool use → approval → įģ§įģ­ã€‚ +- ai-sdk įš„ retry / 错蝝处ᐆį™Ŋ送īŧˆ`messages.status === "error"` į›´æŽĨ昞į¤ēīŧ‰ã€‚ +- 后įģ­ P3 markdown delta å¯į›´æŽĨ hook 到 useChat įš„ onChunk。 + +#### éŖŽé™Š + +- useChat įš„ chat session lifecycleīŧšæĸ model / æĸ chatId æ—ļ message buffer 重įŊŽã€‚MitigationīŧšchatId åšæˆį¨ŗåŽš jotai atom (`agentChatIdAtom`)īŧŒåˆ‡æĸæ—ļ昞åŧ reset。 +- SSE 中断æĸ复īŧšįŊ‘į윿Š–æ–­ → useChat 内部 retry 或 manualīŧŸv1 先䞝čĩ–éģ˜čޤīŧ›status=error æ—ļ昞į¤ē "Retry" 按钎īŧˆæ‰‹åЍīŧ‰ã€‚ + +#### énjæ”ļ + +- 厌整 turnīŧšå‘æļˆæ¯ → æĩåŧå›ž → tool call → approval → tool result → įģ§įģ­ â†’ done。 +- åˆˇæ–°éĄĩéĸ后 chatId 持䚅īŧˆchatstore 拉īŧ‰ã€‚ + +--- + +### P0.6 — cmdblock-input mode=agent čˇ¯į”ąīŧˆS, ~0.5dīŧ‰ + +- **Warp 寚åē”**īŧš`agent_view/agent_message_bar.rs`īŧˆwarp įš„ input barīŧ‰+ `app/src/ai/blocklist/input_model.rs:50-121`īŧˆ`InputConfig` mode 切æĸīŧ‰ã€‚ +- **Crest įŠļ态**īŧšcmdblock-input.tsx 厞įģæ˜¯æ›´åŽŒæ•´įš„ input barīŧŒNLD mode 切æĸ厞įģåœ¨ `frontend/app/term/nld/nld-model.ts` port čŋ‡ã€‚čŋ™ä¸€æ­ĨåĒ是 parent wiring。 +- **Crest target**īŧš`frontend/app/view/cmdblock/cmdblock-input.tsx` įš„ parent (`TerminalView`)。 +- **Port į­–į•Ĩ**īŧšcrest į‰šæœ‰įš„čŋžæŽĨåą‚īŧŒæ—  warp æēį  port。 + +#### 子äģģåŠĄ + +1. `frontend/app/view/cmdblock/cmdblock-input.tsx`īŧšonSubmit æŽĨ `mode` å‚æ•°åˇ˛æœ‰ã€‚åœ¨ parent (TerminalView)īŧš + ```tsx + const onSubmit = (text, mode) => { + if (mode === "agent") { + sendMessage({ text }); // useChat + } else { + model.writeToShell(text); + } + }; + ``` +2. agentVisibleAtom 控åˆļīŧšv1 始įģˆ visibleīŧˆcmdblock-input 是 inline įš„īŧŒæœŦæĨå°ąåœ¨åē•部īŧ‰ã€‚å¯éšč—įš„ overlay åŊĸæ€į•™åŽįģ­ã€‚ +3. NLDModel įš„ effectiveMode → å†ŗåŽš mode="auto" æ—ļ厞际čĩ°å“Ēčžšã€‚åˇ˛æœ‰ wiringīŧŒåĒ是 parent æ˛Ąåœ¨į”¨ã€‚ + +#### æ”ļį›Š + +- 输å…ĨæĄ† → agent é“žčˇ¯æŽĨ通。前éĸ phase įš„åˇĨäŊœįģˆäēŽčƒŊé”Žį›˜č§Ļ发。 +- å¤į”¨įŽ°æœ‰ NLDīŧˆterminal čŋ˜æ˜¯ agent č‡Ē动判åˆĢīŧ‰â€”— 莟 warp 行ä¸ēä¸€č‡´ã€‚ + +#### éŖŽé™Š + +- 几䚎无。半夊äŧ°įŽ—åŒ…åĢį̝到į̝æĩ‹č¯•。 + +#### énjæ”ļ + +- mode=agent → čŋ› agent flow。 +- mode=terminal → čŋ› shellīŧˆä¸ŽäģŠå¤ŠčĄŒä¸ē一致īŧ‰ã€‚ +- mode=auto + "list all files" → NLD 判 agent → čĩ° agent。 +- mode=auto + "ls -la" → NLD 判 terminal → čĩ° shell。 + +--- + +### P0.7 — Smoke + citation jump + polishīŧˆS, ~1dīŧ‰ + +- **Warp 寚åē”**īŧš`block/view_impl.rs:655-728`īŧˆcitation click 行ä¸ēīŧ‰ã€‚ + - WarpDocumentation / WarpDriveObject → open in workspace + - WebPage → open external +- **Crest įŽ€åŒ–į‰ˆ**īŧš + - web/doc → `getApi().openExternal(url)` + - file → čˇŗåˆ°å¯šåē” block + æģšåˆ° `LineStart` 行 + - history → 复åˆļ到å‰Ēč´´æŋ +- **Port į­–į•Ĩ**īŧšclick handler logic 莟 warp 寚éŊīŧŒäŊ† crest æ˛Ąæœ‰ WarpDrive æĻ‚åŋĩīŧŒsimplify 成 web/file/history/doc å››į§ kind。 + +#### 子äģģåŠĄ + +1. 跑 `docs/term-engine-migration.md` § "Testing plan" įš„ 1–11 —— įĄŽäŋ shell flow 无回åŊ’。 +2. 新åĸž agent smokeīŧš + - æ™Žé€šå¯šč¯åž€čŋ” + - `read_file` 一æŦĄåˇĨå…ˇč°ƒį”¨ + - `write_file` 一æŦĄ approval + - `web_fetch` énj蝁 citation chip +3. File citation chip įš„"莺čŊŦ到 block + æģšåˆ°čĄŒ"åŽžįŽ°īŧˆP0.4 į•™įš„ TODOīŧ‰ã€‚ +4. AgentBlockElement 视觉 polishīŧšstreaming cursorã€é—´čˇã€å­—åˇã€ä¸Ž shell block 视觉åŒē分åēĻ。 +5. 厞įŸĨ bug äŋŽå¤ buffer。 + +#### æ”ļį›Š + +- į”¨æˆˇå¯įœŸį”¨ agent åš˛æ´ģīŧŒä¸æ˜¯ demo įŠļ态。 +- File citation 莺čŊŦé—­įŽ¯ → 后įģ­ P2 åŧ•į”¨æ–‡äģļåšæž„æ¸…äšŸį›´æŽĨ work。 + +#### éŖŽé™Š + +- 莺čŊŦ到 block + æģšåˆ°čĄŒéœ€čρ `BlockListElement.scrollToBlock(blockId, line)`。可čƒŊ多 1-2 小æ—ļ。 + +#### énjæ”ļ + +- į̝到į̝īŧšåŧ€įģˆį̝ → 莑几ä¸Ē shell å‘Ŋäģ¤ â†’ 切 agent → 莊 agent 扞一ä¸Ē文äģļ → į‚š citation chip → čˇŗåˆ°å¯šåē” block é̘äēŽį›Žæ ‡čĄŒã€‚ + +--- + +### P0 æ¨Ē向 + +- 每 phase åŽŒæˆčˇ‘ `npx tsc --noEmit` + į›¸å…ŗå•æĩ‹ã€‚ +- P0 į쓿Ÿæ›´æ–° `docs/term-engine-migration.md` Track C äģŽ đŸ“‹ 攚 ✅īŧŒåŠ  P19 åŽŒæˆčŽ°åŊ•。 +- P0 į쓿Ÿåœ¨ `docs/agent-architecture.md` 加 §13 "Agent UI reconnection on new engine"īŧŒæ˛ŋį”¨įŽ°æœ‰ 1–12 įĢ čŠ‚įš„ä甿Žĩåŧīŧˆé—Žéĸ˜/æ–šæĄˆ/文äģļ/数捎æĩ/å–čˆīŧ‰ã€‚ +- Citation chip click 行ä¸ēīŧˆweb→外部īŧŒfileâ†’čˇŗčŊŦīŧŒhistory→å‰Ēč´´æŋīŧ‰å†™čŋ› `docs/agent-user-guide.md`。 + +### P0 åŽŒæˆåŽč§Ŗé” + +``` +P0 厌成 → č§Ŗé”æ‰€æœ‰ P1.4 / P2 / P3 / P4 +├─ P1.1 / P1.2 / P1.3 / P1.5 → ✅ åˇ˛åŽŒæˆīŧˆbackendīŧ‰ +├─ P1.4 chip æ¸˛æŸ“ → ✅ 在 P0.4 里厌成 +├─ P2 ask_user_question → 觪锁īŧŒ~3 夊īŧˆcard pattern åˇ˛æœ‰īŧ‰ +├─ P3 Markdown delta → 觪锁īŧŒå…ˆį”¨ P0.3 įš„ profile 数捎判断是åĻᜟį“ļéĸˆ +└─ P4 é•ŋå‘Ŋäģ¤åˇĨå…ˇįģ„ â†’ 觪锁 +``` + +--- + +## P1 — Typed Citationsīŧˆ~3 夊īŧ‰ + +### 子äģģåŠĄ + +1. `pkg/aiusechat/uctypes/uctypes.go` 加 `Citation` įąģ型īŧš`{ kind: "web"|"doc"|"history"|"file"; url?: string; title: string; line_range?: [int,int] }`。挂到 `UIMessageDataToolUse` 上īŧˆčŽŠ SSE č‡Ēį„ļå¸Ļå‡ēæĨīŧ‰īŧŒåŒæ—ļ复åˆļ一äģŊčŋ› `ToolAuditEvent` äģĨäžŋ trajectory 回攞。 +2. 三ä¸Ēäē§į”Ÿåŧ•į”¨įš„åˇĨå…ˇåĄĢ字æŽĩīŧš`pkg/agent/tools/web_fetch.go`īŧˆwebīŧ‰ã€`pkg/agent/tools/search.go`īŧˆfile+line_rangeīŧ‰ã€`pkg/agent/tools/cmd_history.go`īŧˆhistoryīŧ‰ã€‚ +3. `frontend/app/view/term/term-agent.tsx` 加 `TermAgentCitationChips` įģ„äģļīŧšicon + æˆĒæ–­åŽįš„ title + click → open URL / 打åŧ€æ–‡äģļåˆ°æŒ‡åŽščĄŒã€‚ +4. 单æĩ‹īŧštypes round-trip、各åˇĨå…ˇ citation åĄĢå……æ­ŖįĄŽã€‚ + +### æ”ļį›Š + +- **äŋĄäģģ**īŧšį”¨æˆˇčƒŊénj蝁 agent å‘Ŋäģ¤įš„æĨæēīŧˆ"čŋ™ä¸Ē grep å‘Ŋä줿˜¯åŸēäēŽ README įš„å“Ē一行"īŧ‰ã€‚ +- **įŽĄé“é“ē莝**īŧšP2、P4 éƒŊčĻåž€ SSE æļˆæ¯ä¸ŠæŒ‚æ–°į쓿ž„īŧŒčŋ™ä¸€æŦĄæŠŠ pattern čĩ°é€šã€‚ +- **åŽĄčŽĄå¯čŋŊæē¯**īŧštrajectory 文äģļ里čƒŊ复原 agent įš„åŧ•į”¨äžæŽīŧŒeval æĄ†æžļ可äģĨæ‹ŋæĨ打分。 + +### éŖŽé™Š + +- 几䚎ä¸ēé›ļ。åĸžé‡å­—æŽĩ、向后å…ŧ厚īŧˆæ—§æļˆæ¯æ—  citation 不åŊąå“æ¸˛æŸ“īŧ‰ã€‚ + +### énjæ”ļ + +- web_fetch / search / cmd_history 三ä¸ĒåˇĨå…ˇįš„čž“å‡ē在 inline-agent block 下斚昞į¤ēå¯į‚šå‡ģįš„ citation chip。 +- `.crest-trajectories/<chatid>.json` 里 audit event åĢ citation 字æŽĩ。 +- chip click 行ä¸ēīŧšweb → æĩč§ˆå™¨īŧ›file → 莺čŊŦ到 block + æģšåˆ° line。 + +--- + +## P2 — `ask_user_question` åˇĨå…ˇīŧˆ~3 夊īŧ‰ + +### 子äģģåŠĄ + +1. `pkg/agent/tools/ask_user_question.go`īŧˆæ–°īŧ‰ã€‚Schemaīŧš`questions: [{ question: string; header: string; options: [{ label, description }]; multiSelect: bool }]`。最多 4 ä¸Ē问éĸ˜ã€æ¯é—Žéĸ˜ 2–4 选项īŧˆå¯šéŊ Claude Code įš„ `AskUserQuestion` åˇĨå…ˇīŧ‰ã€‚ +2. `pkg/aiusechat/uctypes/uctypes.go` 加 `AskUserQuestionPayload` 到 `UIMessageDataToolUse`。åˇĨå…ˇįš„ `ToolVerifyInput` æ°¸čŋœčŋ”回 `NeedsApproval`īŧˆ"approval" åŗ"į”¨æˆˇäŊœį­”"īŧ‰ã€‚ +3. `frontend/app/view/term/term-agent.tsx` 加 `TermAgentAskCard`īŧšæŒ‰é’ŽįŊ‘æ ŧ + 可选 "Other" č‡Ēį”ąčž“å…Ĩã€‚é”Žį›˜éŠąåŠ¨īŧˆ1–9 选 optionīŧŒEnter 提äē¤īŧ‰ã€‚ +4. 回写īŧšį”¨æˆˇæäē¤åŽæŠŠ answers äŊœä¸ē `tool_result` å†…åŽšåĄžå›žīŧŒčŋ›å…Ĩ下一æ­Ĩ。 + +### æ”ļį›Š + +- **少čĩ°åŧ¯čˇ¯**īŧšæļˆé™¤ "agent ጜ → čĩ°é”™ → į”¨æˆˇå›žæģš" įš„æĩĒč´ščŊŽæŦĄã€‚ភäŧ° 20–30% 多æ­ĨäģģåŠĄå­˜åœ¨æ­§äš‰į‚šã€‚ +- **token ᜁ**īŧšä¸€ä¸Ē multi-choice card ≈ 50 tokenīŧŒæ¯” agent č‡Ēåˇą generate 三æŽĩæž„æ¸…č¯æœ¯įœåž—å¤šã€‚ +- **质量äŋĄåˇ**īŧšagent å­Ļäŧš"åœ¨ä¸įĄŽåŽšæ—ļ问"比"įĄŦጜ"更可靠īŧŒé•ŋ期可čƒŊ拉éĢ˜æ•´äŊ“æˆåŠŸįŽ‡ã€‚ + +### éŖŽé™Š + +- agent æģĨᔍīŧšäģ€äšˆéƒŊ闎一下。Mitigationīŧšåœ¨ system prompt 里加įēĻæŸ "åĒåœ¨įĄŽåŽžå­˜åœ¨åˆ†æ­§čˇ¯åž„æ—ļäŊŋᔍ"īŧŒä¸” `ask_user_question` 不计 `MaxSteps` ä¸­įš„ LLM č°ƒį”¨īŧˆä¸į„ļ 50 æ­Ĩéĸ„įŽ—é‡ŒåĄžæģĄé—Žéĸ˜å°ąåēŸäē†īŧ‰ã€‚ + +### énjæ”ļ + +- agent 在 ambiguous åœē景īŧˆåĻ‚ "fix the bug" — äŊ† repo 里有 3 ä¸Ē bugīŧ‰čƒŊ调čĩˇ card。 +- é”Žį›˜ 1–9 + Enter 厌成äŊœį­”īŧŒį„Ļį‚šå›žåŊ’ cmdblock。 +- 选 "Other" æ—ļåŧšå‡ē free-form 输å…ĨæĄ†ã€‚ +- äŊœį­”čްåŊ•写å…Ĩ audit log。 + +--- + +## P3 — Markdown delta æ¸˛æŸ“īŧˆ~3–5 夊īŧ‰ + +### 子äģģåŠĄ + +1. **先 profile**īŧˆåŠå¤Šīŧ‰īŧšåœ¨ `frontend/app/view/term/term-agent.tsx` åŊ“å‰įš„ markdown æ¸˛æŸ“čˇ¯åž„ä¸‹īŧŒį”¨ React DevTools Profiler 跑一ä¸Ē 500 行 + 5 ä¸ĒäģŖį å—įš„æĩåŧå›žį­”。**åĻ‚æžœ commit æ—ļé—´æ˛Ąæ˜Žæ˜žé—Žéĸ˜īŧŒæœŦäģģåŠĄį›´æŽĨå…ŗé—­**。 +2. įĢ¯åŖ Warp įš„ `compute_formatted_text_delta`īŧšįē¯ TS functionīŧŒčž“å…Ĩ是新旧两ä¸Ē `FormattedTextLine[]`īŧŒčž“å‡ē `{ commonPrefix: number; oldSuffix: Line[]; newSuffix: Line[] }`。äŊįŊŽīŧš`frontend/app/view/term/term-agent-markdown.tsx`īŧˆæ–°īŧ‰ã€‚ +3. ᔍ stable key + memoization åŒ…čŖš `commonPrefix` 部分īŧŒåĒé‡æ¸˛æŸ“ `newSuffix`。äģŖį å—čĩ° React.memo + æˇąæ¯”čžƒ propsīŧˆäģŖį å—å†…åŽšä¸å˜å°ąåˆĢ重 highlightīŧ‰ã€‚ +4. 单æĩ‹īŧšdelta įŽ—æŗ•ã€prefix 匚配、įŠē case。 + +### æ”ļį›Š + +- **æĩåŧäŊ“感**īŧšé•ŋå›žį­” + 大äģŖį å—æ—ļīŧŒä¸æģ‘åēĻå¯č§æå‡īŧˆå‰ææ˜¯ P3.1 profile įĄŽčŽ¤äē†į“ļéĸˆīŧ‰ã€‚ +- **CPU ᜁ**īŧšæ¯æĨ一ä¸Ē token 不重新 parse + highlight æ•´į¯‡ markdown。 +- **可量化**īŧšprofile 数捎可äģĨ变成 PR 描čŋ°é‡Œįš„ before/after æˆĒ回。 + +### éŖŽé™Š + +- **可čƒŊ是ä¸ĒäŧĒ问éĸ˜**īŧšReact reconciliation + įŽ°æœ‰ key į­–į•Ĩ厞įģå¤ŸåĨŊ。čŋ™å°ąæ˜¯ä¸ēäģ€äšˆå…ˆ profile。 +- äģŖį å—įš„ syntax highlighterīŧˆShiki/Prismīŧ‰æœŦčēĢ可čƒŊ是į“ļéĸˆč€Œä¸æ˜¯ markdown parseīŧŒčŋ™į§æƒ…å†ĩ下 delta įŽ—æŗ•ä¸č§Ŗå†ŗé—Žéĸ˜īŧŒåž—æĸ highlight į­–į•Ĩ。 + +### énjæ”ļ + +- profile 昞į¤ē commit æ—ļ间下降īŧˆå…ˇäŊ“阈å€ŧį•™įģ™ profile į쓿žœåޚīŧ‰ã€‚ +- č§†č§‰æ— å›žåŊ’īŧˆdiff 一下 before/after æˆĒåąīŧ‰ã€‚ +- 单æĩ‹čφᛖ delta įŽ—æŗ• â‰Ĩ3 ä¸Ē case。 + +--- + +## P4 — é•ŋæ—ļ间å‘Ŋäģ¤åˇĨå…ˇįģ„īŧˆ~1 周īŧ‰ + +### 子äģģåŠĄ + +1. **扊 shell_exec**īŧš`pkg/agent/tools/shell_exec.go` 加 `wait_until_completion: bool` 输å…Ĩ字æŽĩ。`false` æ—ļ不é˜ģåĄžīŧŒįĢ‹åˆģčŋ”回 `{ block_id, snapshot, status: "running" }`。 +2. **`read_long_running` åˇĨå…ˇ**īŧš`pkg/agent/tools/long_running_read.go`īŧˆæ–°īŧ‰ã€‚čž“å…Ĩ `{ block_id, delay_ms?: number }`īŧŒäģŽ `pkg/jobmanager` įš„ cirbuf 拉åŊ“前 snapshotīŧŒčŋ”回最后 N 行 + 是åĻčŋ˜åœ¨ running。 +3. **`write_long_running` åˇĨå…ˇ**īŧš`pkg/agent/tools/long_running_write.go`(新)ã€‚čž“å…Ĩ `{ block_id, input: string, mode: "stdin"|"control" }`。control æ¨Ąåŧå‘ `^C`/`^D`/`^Z`。 +4. **`transfer_to_user` åˇĨå…ˇ**īŧš`pkg/agent/tools/transfer_to_user.go`īŧˆæ–°īŧ‰ã€‚čž“å…Ĩ `{ block_id, reason: string }`。后įĢ¯æŠŠ PTY 所有权切到前į̝īŧŒagent turn į쓿Ÿã€‚ +5. **UI**īŧš`frontend/app/view/cmdblock/cmdblock-status.tsx` 加 "agent watching" åžŊįĢ īŧ›åŗä¸Šč§’ "take over" 按钎īŧˆæ‰‹åЍč§Ļ发īŧŒį­‰äģˇ agent 调 transfer_to_userīŧ‰ã€‚ +6. **PTY 所有权**īŧšäģ”į솿ĩ‹ `vim`、`top`、`python -i`、`npm run dev`、`tail -f`。所有权į§ģäē¤åŽ stdin čˇ¯į”ąčĻæ”šīŧˆä¸čƒŊ再čĩ° LLM tool callīŧ‰ã€‚ +7. **Approval policy**īŧštransfer_to_user čĩ°č‡Ē动 approveīŧˆagent ä¸ģåŠ¨čŽŠæƒæ˜¯åĨŊ行ä¸ēīŧŒä¸åĄį”¨æˆˇīŧ‰īŧ›write_long_running įš„ control æ¨Ąåŧīŧˆå‘ ^Cīŧ‰čρčĩ° NeedsApprovalīŧˆæ€čŋ›į¨‹æ˜¯į ´åæ€§įš„īŧ‰ã€‚ + +### æ”ļį›Š + +- **č§Ŗé”æ–°åˇĨäŊœæĩ**īŧšäģŠå¤Š agent 跑 dev server åŋ…éĄģį­‰åˆ°å¤Šč’åœ°č€æˆ–č€…æ šæœŦ跑不äē†ã€‚有äē†čŋ™įģ„åˇĨå…ˇåŽčƒŊ"agent å¯æœåŠĄ → įœ‹æ—Ĩåŋ— → énj蝁 endpoint → æŠĨ告 + čŽŠį”¨æˆˇæŽĨįŽĄ"åŽŒæ•´é—­įŽ¯ã€‚ +- **čƒŊ力寚éŊ Warp/Claude Code**īŧšį”¨æˆˇäģŽé‚Ŗä¸¤ä¸ĒåˇĨå…ˇåˆ‡čŋ‡æĨ不äŧšč§‰åž— crest å°‘æ šį­‹ã€‚ +- **é•ŋ期äģˇå€ŧ**īŧščŋ™įģ„åˇĨå…ˇæ˜¯åŽįģ­åš "browser/E2E æĩ‹č¯• agent"、"daemon č°ƒč¯• agent" įš„åŸēįĄ€čŽžæ–Ŋ。 + +### éŖŽé™Š + +- **æœ€å¤§éŖŽé™ŠīŧšPTY 所有权į§ģäē¤įš„čžšį•Œ**。FD 谁æ‹Ĩ有、äŋĄåˇč°æ”ļ、stdin čˇ¯į”ąåˆ‡æĸįš„æ—ļæœē —— čŋ™äē›é”™äē†äŧšå‡ēįŽ°"į”¨æˆˇæ•˛é”Žį›˜ agent æ”ļ到"æˆ–č€…"agent æƒŗå‘ ^C äŊ† PTY 厞įģįģ™į”¨æˆˇäē†"。æĩ‹č¯•įŸŠé˜ĩåŋ…éĄģčĻ†į›–å‰éĸåˆ—įš„ 5 įąģäē¤äē’åŧį¨‹åēã€‚ +- **įŠļæ€æŗ„éœ˛**īŧšagent å¯åŠ¨įš„ long-running čŋ›į¨‹åœ¨ agent turn į쓿ŸåŽä¸čƒŊč‡Ē动 killīŧŒäŊ†äšŸä¸čƒŊæ°¸čŋœæ´ģį€ã€‚éœ€čĻæ˜ŽįĄŽ"block å…ŗé—­åŗ kill" æˆ–č€…"į”¨æˆˇæ˜žåŧ kill"。åģēčŽŽå‰č€…īŧŒå¯šéŊįŽ°æœ‰ cmdblock į”Ÿå‘Ŋ周期。 + +### énjæ”ļ + +- agent čƒŊ跑 `npm run dev` åšļ在不é˜ģåĄžåŊ“前 turn įš„æƒ…å†ĩ下įģ§įģ­åŽįģ­åˇĨäŊœã€‚ +- agent čƒŊč¯ģ取 long-running å—įš„æœ€æ–° N 行输å‡ē。 +- agent čƒŊ发 ^C į숿­ĸīŧˆčĩ° approvalīŧ‰ã€‚ +- agent čƒŊ调 `transfer_to_user`īŧŒå—éĄļ部å‡ēįŽ°"控åˆļæƒåˇ˛äē¤čŋ˜"提į¤ēīŧŒstdin čˇ¯į”ąåˆ°į”¨æˆˇã€‚ +- vim/top/python -i 三ä¸Ēäē¤äē’åŧ case 不å‡ēįŽ°æŒ‰é”Žčˇ¯į”ąæˇˇäšąã€‚ + +--- + +## æ¨Ē向åˇĨį¨‹éĄšīŧˆč´¯įŠŋ四ä¸Ē phaseīŧ‰ + +- **įąģåž‹į”Ÿæˆ**īŧšæ¯ä¸Ē phase 新åĸžįš„ Go įąģ型īŧˆCitation、AskUserQuestionPayload、LongRunningSnapshotīŧ‰æ”šåŽŒčˇ‘ `task generate`īŧŒå¯šéŊ `frontend/types/gotypes.d.ts`。 +- **trajectory 字æŽĩ**īŧšå››ä¸Ē phase éƒŊäŧšæ‰Š `ToolAuditEvent`。äŋæŒå‘后å…ŧ厚īŧˆæ—§ trajectory įŧē字æŽĩæ—ļ frontend čƒŊäŧ˜é›…降įē§īŧ‰ã€‚ +- **eval harness**īŧšæ¯ä¸Ē phase 写臺少 1 ä¸Ē golden transcript čŋ› `pkg/agent/eval/`īŧŒé˜˛å›žåŊ’。 +- **`docs/agent-architecture.md` åĸžįĢ čŠ‚**īŧšæ¯ä¸Ē phase 厌成后čŋŊ加一ä¸Ēįŧ–åˇå°čŠ‚īŧˆ# 13、14、15、16īŧ‰īŧŒčˇŸįŽ°æœ‰ 1–12 įĢ čŠ‚äŋæŒåŒæ ˇįš„"问éĸ˜/æ–šæĄˆ/文äģļ/数捎æĩ/å–čˆ"ä甿Žĩåŧã€‚ + +--- + +## ä¸åœ¨čŽĄåˆ’å†…īŧˆå‚č€ƒ warp-agent-analysis.md §3 "Dropped from roadmap"īŧ‰ + +- 不做īŧšper-call `rationale` / `is_read_only` / `is_risky`、ancestor `CREST.md` æ‰Ģæã€čˇ¨åŽ‚å•† `~/.<vendor>/skills/` įēĻ厚。 +- 推čŋŸīŧˆHonorable mentionsīŧ‰īŧšCodebase RAG、Conversation/task DAG、read-only session mode。čŋ™äē›åœ¨ P4 äš‹åŽå†č§†į”¨æˆˇåéĻˆå†ŗåŽšæ˜¯åĻčŋ›å…Ĩ下一čŊŽã€‚ + +--- + +## Audit findings (2026-05-20) — strict-port pass + +After P1 / P2 / P4 landed, a comparison pass was done against the +relevant warp source files to verify the implementations actually +mirror warp's data shapes (rather than being invented loosely on a +warp-shaped skeleton). Findings classified A (åŋ…攚) / B (åˆį†ååˇŽ) / +C (äŋį•™ — 有 crest äž§įš„å…ˇäŊ“į†į”ą). A-class fixes are applied; C-class +decisions are recorded here as the long-form rationale. + +### A-class (fixed) + +| Fix | Files | Warp reference | +|---|---|---| +| **A1** AskUserQuestion type discriminator + drop `description`/`header` | `pkg/aiusechat/uctypes/uctypes.go`, `pkg/agent/tools/ask_user_question.go`, `pkg/agent/tools/ask_user_question_test.go`, `frontend/app/store/aitypes.ts` | `crates/ai/src/agent/action/mod.rs:611-631` | +| **A2** FE Tab → Left/Right nav + `ToolAskSummary` completed-state | `frontend/app/term/render/tool-ask-card.tsx`, `frontend/app/term/render/tool-use-card.tsx` | `app/src/ai/blocklist/inline_action/ask_user_question_view.rs:1400-1401` (left/right keys); `:1251-1310` (completed renders) | +| **A3** OnCompletion delay mode on `long_running_read` | `pkg/agent/tools/long_running_read.go`, `pkg/agent/tools/long_running_test.go` | `crates/ai/src/agent/action/mod.rs:126-129, 756-760` | +| **A4** Raw/Line/Block modes on `long_running_write` + drop signal-sending | `pkg/agent/tools/long_running_write.go`, `pkg/agent/tools/long_running_test.go`, `pkg/agent/registry.go` | `crates/ai/src/agent/action/mod.rs:61-65, 762-812` | + +Test result after A-class: `go test ./pkg/agent/tools/ ./pkg/aiusechat/uctypes/` all green (~50 cases). + +### C-class (deliberate crest extensions / retentions) + +Each is a divergence from warp's source. Kept rather than fixed +because the divergence has a real crest-side justification. In-code +comments at the divergence point also reference this section. + +1. **`Citation.kind = "file" | "history"` + `LineStart` / `LineEnd` fields** + - Warp's `AIAgentCitation` (`crates/ai/src/agent/citation.rs:5-11`) has three variants only: `WarpDriveObject` / `WarpDocumentation` / `WebPage`. + - Crest adds `file` and `history` kinds plus line-range fields because crest's `search` tool emits `file:line` citations and `cmd_history` emits prior-command citations — neither tool surface exists on warp's side, so warp's enum genuinely doesn't cover the use case. + - Source note: `pkg/aiusechat/uctypes/uctypes.go` Citation type comment. + +2. **`long_running_read.tail_bytes` parameter** + - Warp's `ReadShellCommandOutput` (`action/mod.rs:126-129`) takes `{ block_id, delay }` only — no return-size cap. + - Crest's agent runs against an LLM context window; an unbounded read can wreck the next turn's budget. The `tail_bytes` cap (default 4 KB, max 32 KB) is the LLM-context guard warp doesn't need. + - Source note: `pkg/agent/tools/long_running_read.go` const block comment. + +3. **`transfer_to_user.block_id` parameter** + - Warp's `TransferShellCommandControlToUser` (`action/mod.rs:161-165`) carries only `{ reason }` — the warp agent loop maintains implicit "current block" session state, so an explicit block reference isn't needed. + - Crest's Go-side tool system has no implicit session-block context; an agent may have started multiple background blocks in one turn. Explicit `block_id` is required for disambiguation. + - Source note: `pkg/agent/tools/transfer_to_user.go` file-level comment (already in place). + +4. **`tool-ask-card` cancel key = Esc (vs warp's Ctrl-C)** + - Warp uses Ctrl-C (`ask_user_question_view.rs:759`) because that's the terminal-environment convention for cancel. + - Crest's card lives inside an Electron surface where Esc is the universal "dismiss dialog" key; Ctrl-C is reserved for the surrounding shell. + - Source note: `frontend/app/term/render/tool-ask-card.tsx` keyboard handler comment. + +5. **`long_running_write` does NOT send signals** + - This was the inverse of a divergence: my v1 invented `mode=control` + signal-sending, which warp's `WriteToLongRunningShellCommand` does NOT have. Fixed in A4 — signals dropped, modes match warp's `AIAgentPtyWriteMode` (raw / line / block). + - Open question: how should the agent kill a runaway background process if needed? Warp's source doesn't seem to expose a kill-signal action to the agent at all; that's a deliberate safety scope. If crest later wants to expose it, do it as a separate `signal_block` tool with explicit approval, not as a mode of `long_running_write`. + +### Non-strict areas not yet audited + +- **`ask_user_question` FE UX details** beyond the keyboard-binding decision. Warp's view file is ~1700 LOC with multiple render branches (active / unavailable / completed / finished — `ask_user_question_view.rs:1183-1450`). Crest implements active + completed (via `ToolAskSummary`); unavailable / finished states are deferred. +- **`long_running_write.line` mode LF vs CR on Windows hosts.** Warp picks per-platform (`action/mod.rs:790-797`); crest v1 always uses LF. Acceptable for POSIX hosts (the bulk of crest users today); revisit if a Windows-host user reports trouble. +- **`long_running_write.block` mode `is_bracketed_paste_enabled` gate.** Warp checks this state before wrapping (`action/mod.rs:799-810`); crest v1 always wraps. Worst case: a shell without bracketed-paste support shows the markers as literal text — recoverable noise, not data loss. diff --git a/electron-builder.config.cjs b/electron-builder.config.cjs new file mode 100644 index 000000000..3ea2ab9c3 --- /dev/null +++ b/electron-builder.config.cjs @@ -0,0 +1,140 @@ +const { Arch } = require("electron-builder"); +const pkg = require("./package.json"); +const fs = require("fs"); +const path = require("path"); + +const windowsShouldSign = !!process.env.SM_CODE_SIGNING_CERT_SHA1_HASH; + +/** + * @type {import('electron-builder').Configuration} + * @see https://www.electron.build/configuration/configuration + */ +const config = { + appId: pkg.build.appId, + productName: pkg.productName, + executableName: pkg.productName, + artifactName: "${productName}-${platform}-${arch}-${version}.${ext}", + generateUpdatesFilesForAllChannels: true, + npmRebuild: false, + nodeGypRebuild: false, + electronCompile: false, + files: [ + { + from: "./dist", + to: "./dist", + filter: ["**/*", "!bin/*", "bin/wavesrv.${arch}*", "bin/wsh*", "!tsunamiscaffold/**/*"], + }, + { + from: ".", + to: ".", + filter: ["package.json"], + }, + "!node_modules", // We don't need electron-builder to package in Node modules as Vite has already bundled any code that our program is using. + ], + extraResources: [ + { + from: "dist/tsunamiscaffold", + to: "tsunamiscaffold", + }, + ], + directories: { + output: "make", + }, + asarUnpack: [ + "dist/bin/**/*", // wavesrv and wsh binaries + "dist/schema/**/*", // schema files for Monaco editor + ], + mac: { + target: [ + { + target: "zip", + arch: ["arm64", "x64"], + }, + { + target: "dmg", + arch: ["arm64", "x64"], + }, + ], + category: "public.app-category.developer-tools", + minimumSystemVersion: "10.15.0", + mergeASARs: true, + singleArchFiles: "**/dist/bin/wavesrv.*", + entitlements: "build/entitlements.mac.plist", + entitlementsInherit: "build/entitlements.mac.plist", + extendInfo: { + NSContactsUsageDescription: "A CLI application running in Crest wants to use your contacts.", + NSRemindersUsageDescription: "A CLI application running in Crest wants to use your reminders.", + NSLocationWhenInUseUsageDescription: + "A CLI application running in Crest wants to use your location information while active.", + NSLocationAlwaysUsageDescription: + "A CLI application running in Crest wants to use your location information, even in the background.", + NSCameraUsageDescription: "A CLI application running in Crest wants to use the camera.", + NSMicrophoneUsageDescription: "A CLI application running in Crest wants to use your microphone.", + NSCalendarsUsageDescription: "A CLI application running in Crest wants to use Calendar data.", + NSLocationUsageDescription: "A CLI application running in Crest wants to use your location information.", + NSAppleEventsUsageDescription: "A CLI application running in Crest wants to use AppleScript.", + }, + }, + linux: { + artifactName: "${name}-${platform}-${arch}-${version}.${ext}", + category: "TerminalEmulator", + executableName: pkg.name, + target: ["zip", "deb", "rpm", "snap", "AppImage", "pacman"], + synopsis: pkg.description, + description: null, + desktop: { + entry: { + Name: pkg.productName, + Comment: pkg.description, + Keywords: "developer;terminal;emulator;", + Categories: "Development;Utility;", + }, + }, + executableArgs: ["--enable-features", "UseOzonePlatform", "--ozone-platform-hint", "auto"], // Hint Electron to use Ozone abstraction layer for native Wayland support + }, + deb: { + afterInstall: "build/deb-postinstall.tpl", + }, + win: { + target: ["nsis", "msi", "zip"], + signtoolOptions: windowsShouldSign && { + signingHashAlgorithms: ["sha256"], + publisherName: "s-zx", + certificateSubjectName: "s-zx", + certificateSha1: process.env.SM_CODE_SIGNING_CERT_SHA1_HASH, + }, + }, + appImage: { + license: "LICENSE", + }, + snap: { + base: "core22", + confinement: "classic", + allowNativeWayland: true, + artifactName: "${name}_${version}_${arch}.${ext}", + }, + rpm: { + // this should remove /usr/lib/.build-id/ links which can conflict with other electron apps like slack + fpm: ["--rpm-rpmbuild-define", "_build_id_links none"], + }, + publish: null, + afterPack: (context) => { + // This is a workaround to restore file permissions to the wavesrv binaries on macOS after packaging the universal binary. + if (context.electronPlatformName === "darwin" && context.arch === Arch.universal) { + const packageBinDir = path.resolve( + context.appOutDir, + `${pkg.productName}.app/Contents/Resources/app.asar.unpacked/dist/bin` + ); + + // Reapply file permissions to the wavesrv binaries in the final app package + fs.readdirSync(packageBinDir, { + recursive: true, + withFileTypes: true, + }) + .filter((f) => f.isFile() && f.name.startsWith("wavesrv")) + .forEach((f) => fs.chmodSync(path.resolve(f.parentPath ?? f.path, f.name), 0o755)); // 0o755 corresponds to -rwxr-xr-x + } + }, +}; + +module.exports = config; diff --git a/electron.vite.config.ts b/electron.vite.config.ts new file mode 100644 index 000000000..0e6b8e832 --- /dev/null +++ b/electron.vite.config.ts @@ -0,0 +1,233 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react-swc"; +import { defineConfig } from "electron-vite"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { ViteImageOptimizer } from "vite-plugin-image-optimizer"; +import svgr from "vite-plugin-svgr"; +import tsconfigPaths from "vite-tsconfig-paths"; + +// from our electron build +const CHROME = "chrome140"; +const NODE = "node22"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Local sibling clone of the edgeFlow.js library — used for in-tree +// iteration so source edits show up in crest without a publish round-trip. +// Falls back to whatever's in node_modules when the path doesn't exist +// (e.g. CI, fresh clone) so the build still produces a working artifact. +const EDGEFLOW_LOCAL = path.resolve(__dirname, "../edgeFlow.js"); + +// for debugging +// target is like -- path.resolve(__dirname, "frontend/app/workspace/workspace-layout-model.ts"); +function whoImportsTarget(target: string) { + return { + name: "who-imports-target", + buildEnd() { + // Build reverse graph: child -> [importers...] + const parents = new Map<string, string[]>(); + for (const id of (this as any).getModuleIds()) { + const info = (this as any).getModuleInfo(id); + if (!info) continue; + for (const child of [...info.importedIds, ...info.dynamicallyImportedIds]) { + const arr = parents.get(child) ?? []; + arr.push(id); + parents.set(child, arr); + } + } + + // Walk upward from TARGET and print paths to entries + const entries = [...parents.keys()].filter((id) => { + const m = (this as any).getModuleInfo(id); + return m?.isEntry; + }); + + const seen = new Set<string>(); + const stack: string[] = []; + const dfs = (node: string) => { + if (seen.has(node)) return; + seen.add(node); + stack.push(node); + const ps = parents.get(node) || []; + if (ps.length === 0) { + // hit a root (likely main entry or plugin virtual) + console.log("\nImporter chain:"); + stack + .slice() + .reverse() + .forEach((s) => console.log(" â†ŗ", s)); + } else { + for (const p of ps) dfs(p); + } + stack.pop(); + }; + + if (!parents.has(target)) { + console.log(`[who-imports] TARGET not in MAIN graph: ${target}`); + } else { + dfs(target); + } + }, + async resolveId(id: any, importer: any) { + const r = await (this as any).resolve(id, importer, { skipSelf: true }); + if (r?.id === target) { + console.log(`[resolve] ${importer} -> ${id} -> ${r.id}`); + } + return null; + }, + }; +} + +export default defineConfig({ + main: { + root: ".", + build: { + target: NODE, + rollupOptions: { + input: { + index: "emain/emain.ts", + }, + }, + outDir: "dist/main", + externalizeDeps: false, + }, + plugins: [tsconfigPaths()], + resolve: { + alias: { + "@": "frontend", + }, + }, + server: { + open: false, + }, + define: { + "process.env.WS_NO_BUFFER_UTIL": "true", + "process.env.WS_NO_UTF_8_VALIDATE": "true", + }, + }, + preload: { + root: ".", + build: { + target: NODE, + sourcemap: true, + rollupOptions: { + input: { + index: "emain/preload.ts", + "preload-webview": "emain/preload-webview.ts", + }, + output: { + format: "cjs", + }, + }, + outDir: "dist/preload", + externalizeDeps: false, + }, + server: { + open: false, + }, + plugins: [tsconfigPaths()], + }, + renderer: { + root: ".", + resolve: { + // Resolve edgeflowjs to the sibling repo's source when present, + // so iteration in /Users/.../edgeFlow.js is instant. See + // EDGEFLOW_LOCAL above. Drop this block to fall back to the + // published npm package. + alias: existsSync(EDGEFLOW_LOCAL) + ? [ + { + find: /^edgeflowjs$/, + replacement: path.resolve(EDGEFLOW_LOCAL, "src/index.ts"), + }, + ] + : [], + }, + // ES-module workers so dynamic imports inside worker files work. + // edgeFlow.js's onnx backend does `await import('onnxruntime-web/wasm')` + // inside the worker (src/backends/onnx.ts:92); the default IIFE worker + // format silently strips the dynamic import in dev and outright fails + // the production build with: + // [vite:worker] Invalid value "iife" for option "worker.format" — + // UMD and IIFE output formats are not supported for code-splitting builds. + // TODO(edgeflow): ../edgeFlow.js/docs/INTEGRATION_LOG.md 2026-05-19 — + // edgeFlow's worker-compatible code path forces consumers onto + // worker.format='es'. Once edgeFlow ships a static-only ORT loader, + // this override can go. + worker: { + format: "es", + }, + build: { + target: CHROME, + sourcemap: true, + outDir: "dist/frontend", + rollupOptions: { + input: { + index: "index.html", + }, + output: { + manualChunks(id) { + const p = id.replace(/\\/g, "/"); + if (p.includes("node_modules/monaco") || p.includes("node_modules/@monaco")) return "monaco"; + if (p.includes("node_modules/mermaid") || p.includes("node_modules/@mermaid")) return "mermaid"; + if (p.includes("node_modules/katex") || p.includes("node_modules/@katex")) return "katex"; + if (p.includes("node_modules/shiki") || p.includes("node_modules/@shiki")) { + return "shiki"; + } + if (p.includes("node_modules/cytoscape") || p.includes("node_modules/@cytoscape")) + return "cytoscape"; + return undefined; + }, + }, + }, + }, + optimizeDeps: { + include: ["monaco-yaml/yaml.worker.js"], + }, + server: { + open: false, + // Allow Vite to read source files from the sibling edgeFlow.js + // clone. Default fs.allow is the workspace root only, so + // /Users/.../edgeFlow.js would otherwise 403. + fs: existsSync(EDGEFLOW_LOCAL) + ? { allow: [path.resolve(__dirname), EDGEFLOW_LOCAL] } + : undefined, + watch: { + ignored: [ + "dist/**", + "**/*.go", + "**/go.mod", + "**/go.sum", + "**/*.md", + "**/*.mdx", + "**/*.json", + "**/emain/**", + "**/*.txt", + "**/*.log", + ], + }, + }, + css: { + preprocessorOptions: { + scss: { + silenceDeprecations: ["mixed-decls"], + }, + }, + }, + plugins: [ + tsconfigPaths(), + { ...ViteImageOptimizer(), apply: "build" }, + svgr({ + svgrOptions: { exportType: "default", ref: true, svgo: false, titleProp: true }, + include: "**/*.svg", + }), + react({}), + tailwindcss(), + ], + }, +}); diff --git a/emain/agent-ipc.ts b/emain/agent-ipc.ts new file mode 100644 index 000000000..182ce7f76 --- /dev/null +++ b/emain/agent-ipc.ts @@ -0,0 +1,275 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +// +// agent-ipc.ts — Electron main IPC surface for the integrated agent +// runtime. Holds the harness cache (Map<sessionPath, PaneHarness>), +// registers ipcMain handlers, and fans pi AgentHarness events out to +// per-sender subscribers via a single "agent:event" channel. +// +// See docs/agent-runtime-architecture.md §2 for the topology and +// §6 for the per-pane lifecycle this layer implements. +// +// IPC contract (mirrored in preload.ts + ElectronApi.agent typings): +// +// handle "agent:create-session" (cwd) → AgentSessionMeta +// handle "agent:list-sessions-for-cwd" (cwd) → AgentSessionMeta[] +// handle "agent:send" (opts) → { sessionMetadata } +// - opts.sessionMetadata? null → main mints a fresh session +// - prompt() runs in background; events stream via "agent:event" +// - returns the resolved sessionMetadata (renderer writes it to +// block.meta after first send) +// on "agent:abort" (sessionPath) +// on "agent:subscribe" (sessionPath) +// - subscriber tracked per-sender; renderer must call unsubscribe +// on cleanup, but sender 'destroyed' also releases automatically +// on "agent:unsubscribe" (sessionPath) +// +// Event stream payload (sent on "agent:event"): +// { sessionPath: string, event: AgentHarnessEvent } +// +// Single channel + payload-identified subscriptions matches the +// "dir-changed" pattern in emain-ipc.ts (security: renderer-supplied +// strings never end up in channel names). + +import * as electron from "electron"; + +import type { Api, Model } from "./ai"; +import { getModel } from "./ai"; +import { buildPaneHarness, type PaneHarness } from "./agent/harness-factory"; +import type { SystemPromptInputs } from "./agent/build-system-prompt"; +import { buildPermissionsHook, isBenchMode } from "./agent/permissions"; +import { getDefaultTools } from "./agent/tools"; +import { + createPaneSession, + listSessionsForCwd, + openPaneSession, +} from "./agent/sessions"; +import type { JsonlSessionMetadata } from "./agent/harness/types"; +import type { ThinkingLevel } from "./agent/types"; + +// Per-pane harness instances, keyed by session JSONL path (the natural +// session identity — same path always reopens the same conversation). +const harnessCache = new Map<string, PaneHarness>(); + +// Per-(sender, sessionPath) subscriptions. The value is the unsubscribe +// fn returned by PaneHarness.harness.subscribe(). On sender destroy we +// walk this map and release everything that sender held. +type SubKey = string; // `${senderId}:${sessionPath}` +const subscriptions = new Map<SubKey, () => void>(); +const subscriptionsBySender = new Map<number, Set<SubKey>>(); + +interface SendOptions { + /** Existing session, if any. null on first send → main mints a fresh one. */ + sessionMetadata?: JsonlSessionMetadata | null; + /** Pane's current cwd. Drives system prompt + tool execution dir. */ + cwd: string; + /** Prompt text. */ + text: string; + /** Resolved provider id (e.g. "openrouter"). */ + provider: string; + /** Resolved model id (e.g. "anthropic/claude-opus-4-7"). */ + model: string; + /** Reasoning level, when the model supports it. */ + reasoning?: ThinkingLevel; + /** Optional pane context. */ + gitBranch?: string; + recentCmds?: string[]; + connection?: string; + /** + * Per-pane tool allowlist. Optional in v1 — when omitted, permissions + * default to allowAll:true (no approval UI exists yet; the agent + * stays functional). Bench mode (CREST_AGENT_BENCH=1) also forces + * allowAll regardless of this value. See emain/agent/permissions.ts. + */ + allowedTools?: string[]; +} + +function resolveModelOrThrow(provider: string, modelId: string): Model<Api> { + // pi's getModel is typed with literal generics; our renderer-supplied + // strings can't satisfy them. Cast — runtime accepts any registered id. + const model = (getModel as unknown as (p: string, m: string) => Model<Api> | undefined)( + provider, + modelId, + ); + if (!model) { + throw new Error(`agent: unknown provider/model "${provider}/${modelId}"`); + } + return model; +} + +function buildPromptInputs(opts: SendOptions): SystemPromptInputs { + return { + cwd: opts.cwd, + gitBranch: opts.gitBranch, + connection: opts.connection, + recentCmds: opts.recentCmds, + }; +} + +async function ensureSession(opts: SendOptions): Promise<{ + metadata: JsonlSessionMetadata; + isNew: boolean; +}> { + if (opts.sessionMetadata) { + return { metadata: opts.sessionMetadata, isNew: false }; + } + const { metadata } = await createPaneSession(opts.cwd); + return { metadata, isNew: true }; +} + +async function ensurePaneHarness( + metadata: JsonlSessionMetadata, + opts: SendOptions, +): Promise<PaneHarness> { + const existing = harnessCache.get(metadata.path); + if (existing) { + existing.update(buildPromptInputs(opts)); + return existing; + } + const session = await openPaneSession(metadata); + const pane = buildPaneHarness({ + session, + model: resolveModelOrThrow(opts.provider, opts.model), + thinkingLevel: opts.reasoning, + promptInputs: buildPromptInputs(opts), + tools: getDefaultTools(), + // Bench mode (eval harness sets CREST_AGENT_BENCH=1) bypasses + // the allowlist entirely. Otherwise: v1 defaults to allowAll + // when the renderer didn't pass an allowedTools list (no + // approval UI yet); future task wires this to a per-pane + // setting written from the renderer. + toolCallHook: buildPermissionsHook( + isBenchMode() + ? { allowAll: true } + : opts.allowedTools + ? { allowAll: false, allowedTools: opts.allowedTools } + : { allowAll: true }, + ), + }); + harnessCache.set(metadata.path, pane); + return pane; +} + +function releaseSubscription(key: SubKey): void { + const unsub = subscriptions.get(key); + if (!unsub) return; + try { + unsub(); + } catch (err) { + // eslint-disable-next-line no-console + console.error("[agent-ipc] unsubscribe error:", err); + } + subscriptions.delete(key); +} + +function releaseAllForSender(senderId: number): void { + const keys = subscriptionsBySender.get(senderId); + if (!keys) return; + for (const key of keys) releaseSubscription(key); + subscriptionsBySender.delete(senderId); +} + +/** + * Wire the agent IPC handlers. Call once at app startup from + * emain-ipc.ts initIpcHandlers(). + */ +export function registerAgentIpcHandlers(): void { + electron.ipcMain.handle( + "agent:create-session", + async (_event, cwd: string): Promise<JsonlSessionMetadata> => { + const { metadata } = await createPaneSession(cwd); + return metadata; + }, + ); + + electron.ipcMain.handle( + "agent:list-sessions-for-cwd", + async (_event, cwd: string): Promise<JsonlSessionMetadata[]> => { + return await listSessionsForCwd(cwd); + }, + ); + + electron.ipcMain.handle( + "agent:send", + async ( + _event, + opts: SendOptions, + ): Promise<{ sessionMetadata: JsonlSessionMetadata }> => { + const { metadata } = await ensureSession(opts); + const pane = await ensurePaneHarness(metadata, opts); + // Fire-and-forget: pi emits errors via the assistant-message + // stop reason on the event stream. We log unexpected throws + // (which would be bugs in our wiring, not LLM errors). + void pane.harness.prompt(opts.text).catch((err) => { + // eslint-disable-next-line no-console + console.error(`[agent-ipc] prompt error for ${metadata.path}:`, err); + }); + return { sessionMetadata: metadata }; + }, + ); + + electron.ipcMain.on("agent:abort", (_event, sessionPath: string) => { + if (typeof sessionPath !== "string" || !sessionPath) return; + const pane = harnessCache.get(sessionPath); + // AgentHarness.abort() returns a Promise<AbortResult>; we don't + // need to await it here — caller fired an abort intent, not a + // synchronous request for completion. + if (pane) void pane.harness.abort(); + }); + + electron.ipcMain.on("agent:subscribe", (event, sessionPath: string) => { + if (typeof sessionPath !== "string" || !sessionPath) return; + const pane = harnessCache.get(sessionPath); + // Subscribe-before-send is legal: the harness may not exist yet. + // Renderer should retry subscribe after the first `agent:send` + // returns. For v1 we drop early subscribes silently; future + // improvement is a pending-subs queue keyed by sessionPath. + if (!pane) return; + const key: SubKey = `${event.sender.id}:${sessionPath}`; + if (subscriptions.has(key)) return; + const unsub = pane.harness.subscribe((agentEvent) => { + if (event.sender.isDestroyed()) return; + event.sender.send("agent:event", { sessionPath, event: agentEvent }); + }); + subscriptions.set(key, unsub); + let set = subscriptionsBySender.get(event.sender.id); + if (!set) { + set = new Set(); + subscriptionsBySender.set(event.sender.id, set); + event.sender.once("destroyed", () => releaseAllForSender(event.sender.id)); + } + set.add(key); + }); + + electron.ipcMain.on("agent:unsubscribe", (event, sessionPath: string) => { + if (typeof sessionPath !== "string" || !sessionPath) return; + const key: SubKey = `${event.sender.id}:${sessionPath}`; + releaseSubscription(key); + const set = subscriptionsBySender.get(event.sender.id); + if (set) { + set.delete(key); + if (set.size === 0) subscriptionsBySender.delete(event.sender.id); + } + }); +} + +/** Test-only escape hatch: clear the harness cache + subscriptions. */ +export function _resetAgentIpcForTests(): void { + for (const unsub of subscriptions.values()) { + try { + unsub(); + } catch { + // ignore + } + } + subscriptions.clear(); + subscriptionsBySender.clear(); + for (const pane of harnessCache.values()) { + try { + void pane.harness.abort(); + } catch { + // ignore + } + } + harnessCache.clear(); +} diff --git a/emain/agent/LICENSE.pi b/emain/agent/LICENSE.pi new file mode 100644 index 000000000..b0a8e9b81 --- /dev/null +++ b/emain/agent/LICENSE.pi @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Mario Zechner + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/emain/agent/_spike.ts b/emain/agent/_spike.ts new file mode 100644 index 000000000..ea82dfba3 --- /dev/null +++ b/emain/agent/_spike.ts @@ -0,0 +1,122 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +// +// _spike.ts — end-to-end smoke for the integrated agent runtime. +// +// Exercises the full stack from sessions.ts → harness-factory.ts → +// AgentHarness → pi-ai → upstream LLM. Confirms: +// - emain/agent + emain/ai compile and load at runtime +// - JsonlSessionRepo mints a JSONL file under the configured root +// - buildPaneHarness wires env / model / system prompt correctly +// - AgentHarness.prompt drives a real turn and emits event stream +// - Session JSONL contains the appended messages afterwards +// +// Usage: +// ANTHROPIC_API_KEY=sk-ant-... npx tsx emain/agent/_spike.ts +// OPENAI_API_KEY=sk-... npx tsx emain/agent/_spike.ts "Tell me a joke" +// GEMINI_API_KEY=... npx tsx emain/agent/_spike.ts +// +// Spike writes session JSONL under a tmp dir (not the user's real +// sessions store) so repeated runs don't pile up real conversations. +// +// Delete this file once the Electron main runtime + IPC wiring is in +// place and exercised by integration tests. + +import { promises as fs } from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { getModel } from "../ai"; +import { JsonlSessionRepo } from "./harness/session/jsonl-repo"; +import { NodeExecutionEnv } from "./node"; +import { buildPaneHarness } from "./harness-factory"; +import { _setSessionsRepoForTests, createPaneSession } from "./sessions"; + +interface ProviderChoice { + provider: "anthropic" | "openai" | "google"; + model: string; + envKey: string; +} + +const CANDIDATES: ProviderChoice[] = [ + { provider: "anthropic", model: "claude-haiku-4-5", envKey: "ANTHROPIC_API_KEY" }, + { provider: "openai", model: "gpt-5-mini", envKey: "OPENAI_API_KEY" }, + { provider: "google", model: "gemini-2.5-flash-lite", envKey: "GEMINI_API_KEY" }, +]; + +function pickProvider(): ProviderChoice { + for (const c of CANDIDATES) { + if (process.env[c.envKey]) return c; + } + throw new Error( + `No provider key found in env. Set one of: ${CANDIDATES.map((c) => c.envKey).join(", ")}`, + ); +} + +async function main(): Promise<void> { + const prompt = process.argv[2] ?? "Reply with the single word OK."; + const pick = pickProvider(); + console.log(`[spike] provider=${pick.provider} model=${pick.model}`); + + // Sandbox the sessions root to a tmp dir so we don't pollute the + // user's real conversation history. + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "crest-spike-")); + const sandboxedEnv = new NodeExecutionEnv({ cwd: process.cwd() }); + const tmpRepo = new JsonlSessionRepo({ fs: sandboxedEnv, sessionsRoot: tmpRoot }); + _setSessionsRepoForTests(tmpRepo); + console.log(`[spike] sessions root: ${tmpRoot}`); + + const { session, metadata } = await createPaneSession(process.cwd()); + console.log(`[spike] minted session id=${metadata.id} at ${metadata.path}`); + + // getModel is typed with literal generics; the loose `pick.provider` + // string can't satisfy them. Cast — runtime accepts any registered id. + const model = (getModel as unknown as (p: string, m: string) => unknown)( + pick.provider, + pick.model, + ); + if (!model) throw new Error(`getModel returned no entry for ${pick.provider}/${pick.model}`); + + const pane = buildPaneHarness({ + session, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + model: model as any, + promptInputs: { + cwd: process.cwd(), + gitBranch: "main", + recentCmds: ["git status", "ls"], + }, + }); + + const eventCounts: Record<string, number> = {}; + // subscribe() gets the full AgentHarnessEvent | AgentEvent union; + // the per-type .on() API is reserved for AgentHarness-OWN hooks + // (queue, save_point, ...), not the underlying Agent's stream events. + pane.harness.subscribe((event) => { + eventCounts[event.type] = (eventCounts[event.type] ?? 0) + 1; + if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + process.stdout.write(event.assistantMessageEvent.delta); + } + }); + + const assistantMessage = await pane.harness.prompt(prompt); + + process.stdout.write("\n\n[spike] event tally:\n"); + for (const [type, count] of Object.entries(eventCounts).sort()) { + process.stdout.write(` ${type}: ${count}\n`); + } + console.log(`[spike] stop reason: ${assistantMessage.stopReason}`); + if (assistantMessage.errorMessage) { + console.log(`[spike] error message: ${assistantMessage.errorMessage}`); + } + + // Verify the session JSONL has appended entries. + const lines = (await fs.readFile(metadata.path, "utf8")).trim().split("\n"); + console.log(`[spike] session jsonl line count: ${lines.length}`); + console.log(`[spike] OK`); +} + +void main().catch((err) => { + console.error("[spike] FAILED:", err); + process.exit(1); +}); diff --git a/emain/agent/agent-loop.ts b/emain/agent/agent-loop.ts new file mode 100644 index 000000000..192fb6e21 --- /dev/null +++ b/emain/agent/agent-loop.ts @@ -0,0 +1,742 @@ +/** + * Agent loop that works with AgentMessage throughout. + * Transforms to Message[] only at the LLM call boundary. + */ + +import { + type AssistantMessage, + type Context, + EventStream, + streamSimple, + type ToolResultMessage, + validateToolArguments, +} from "../ai"; +import type { + AgentContext, + AgentEvent, + AgentLoopConfig, + AgentMessage, + AgentTool, + AgentToolCall, + AgentToolResult, + StreamFn, +} from "./types"; + +export type AgentEventSink = (event: AgentEvent) => Promise<void> | void; + +/** + * Start an agent loop with a new prompt message. + * The prompt is added to the context and events are emitted for it. + */ +export function agentLoop( + prompts: AgentMessage[], + context: AgentContext, + config: AgentLoopConfig, + signal?: AbortSignal, + streamFn?: StreamFn, +): EventStream<AgentEvent, AgentMessage[]> { + const stream = createAgentStream(); + + void runAgentLoop( + prompts, + context, + config, + async (event) => { + stream.push(event); + }, + signal, + streamFn, + ).then((messages) => { + stream.end(messages); + }); + + return stream; +} + +/** + * Continue an agent loop from the current context without adding a new message. + * Used for retries - context already has user message or tool results. + * + * **Important:** The last message in context must convert to a `user` or `toolResult` message + * via `convertToLlm`. If it doesn't, the LLM provider will reject the request. + * This cannot be validated here since `convertToLlm` is only called once per turn. + */ +export function agentLoopContinue( + context: AgentContext, + config: AgentLoopConfig, + signal?: AbortSignal, + streamFn?: StreamFn, +): EventStream<AgentEvent, AgentMessage[]> { + if (context.messages.length === 0) { + throw new Error("Cannot continue: no messages in context"); + } + + if (context.messages[context.messages.length - 1].role === "assistant") { + throw new Error("Cannot continue from message role: assistant"); + } + + const stream = createAgentStream(); + + void runAgentLoopContinue( + context, + config, + async (event) => { + stream.push(event); + }, + signal, + streamFn, + ).then((messages) => { + stream.end(messages); + }); + + return stream; +} + +export async function runAgentLoop( + prompts: AgentMessage[], + context: AgentContext, + config: AgentLoopConfig, + emit: AgentEventSink, + signal?: AbortSignal, + streamFn?: StreamFn, +): Promise<AgentMessage[]> { + const newMessages: AgentMessage[] = [...prompts]; + const currentContext: AgentContext = { + ...context, + messages: [...context.messages, ...prompts], + }; + + await emit({ type: "agent_start" }); + await emit({ type: "turn_start" }); + for (const prompt of prompts) { + await emit({ type: "message_start", message: prompt }); + await emit({ type: "message_end", message: prompt }); + } + + await runLoop(currentContext, newMessages, config, signal, emit, streamFn); + return newMessages; +} + +export async function runAgentLoopContinue( + context: AgentContext, + config: AgentLoopConfig, + emit: AgentEventSink, + signal?: AbortSignal, + streamFn?: StreamFn, +): Promise<AgentMessage[]> { + if (context.messages.length === 0) { + throw new Error("Cannot continue: no messages in context"); + } + + if (context.messages[context.messages.length - 1].role === "assistant") { + throw new Error("Cannot continue from message role: assistant"); + } + + const newMessages: AgentMessage[] = []; + const currentContext: AgentContext = { ...context }; + + await emit({ type: "agent_start" }); + await emit({ type: "turn_start" }); + + await runLoop(currentContext, newMessages, config, signal, emit, streamFn); + return newMessages; +} + +function createAgentStream(): EventStream<AgentEvent, AgentMessage[]> { + return new EventStream<AgentEvent, AgentMessage[]>( + (event: AgentEvent) => event.type === "agent_end", + (event: AgentEvent) => (event.type === "agent_end" ? event.messages : []), + ); +} + +/** + * Main loop logic shared by agentLoop and agentLoopContinue. + */ +async function runLoop( + initialContext: AgentContext, + newMessages: AgentMessage[], + initialConfig: AgentLoopConfig, + signal: AbortSignal | undefined, + emit: AgentEventSink, + streamFn?: StreamFn, +): Promise<void> { + let currentContext = initialContext; + let config = initialConfig; + let firstTurn = true; + // Check for steering messages at start (user may have typed while waiting) + let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || []; + + // Outer loop: continues when queued follow-up messages arrive after agent would stop + while (true) { + let hasMoreToolCalls = true; + + // Inner loop: process tool calls and steering messages + while (hasMoreToolCalls || pendingMessages.length > 0) { + if (!firstTurn) { + await emit({ type: "turn_start" }); + } else { + firstTurn = false; + } + + // Process pending messages (inject before next assistant response) + if (pendingMessages.length > 0) { + for (const message of pendingMessages) { + await emit({ type: "message_start", message }); + await emit({ type: "message_end", message }); + currentContext.messages.push(message); + newMessages.push(message); + } + pendingMessages = []; + } + + // Stream assistant response + const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFn); + newMessages.push(message); + + if (message.stopReason === "error" || message.stopReason === "aborted") { + await emit({ type: "turn_end", message, toolResults: [] }); + await emit({ type: "agent_end", messages: newMessages }); + return; + } + + // Check for tool calls + const toolCalls = message.content.filter((c) => c.type === "toolCall"); + + const toolResults: ToolResultMessage[] = []; + hasMoreToolCalls = false; + if (toolCalls.length > 0) { + const executedToolBatch = await executeToolCalls(currentContext, message, config, signal, emit); + toolResults.push(...executedToolBatch.messages); + hasMoreToolCalls = !executedToolBatch.terminate; + + for (const result of toolResults) { + currentContext.messages.push(result); + newMessages.push(result); + } + } + + await emit({ type: "turn_end", message, toolResults }); + + const nextTurnContext = { + message, + toolResults, + context: currentContext, + newMessages, + }; + const nextTurnSnapshot = await config.prepareNextTurn?.(nextTurnContext); + if (nextTurnSnapshot) { + currentContext = nextTurnSnapshot.context ?? currentContext; + config = { + ...config, + model: nextTurnSnapshot.model ?? config.model, + reasoning: + nextTurnSnapshot.thinkingLevel === undefined + ? config.reasoning + : nextTurnSnapshot.thinkingLevel === "off" + ? undefined + : nextTurnSnapshot.thinkingLevel, + }; + } + + if ( + await config.shouldStopAfterTurn?.({ + message, + toolResults, + context: currentContext, + newMessages, + }) + ) { + await emit({ type: "agent_end", messages: newMessages }); + return; + } + + pendingMessages = (await config.getSteeringMessages?.()) || []; + } + + // Agent would stop here. Check for follow-up messages. + const followUpMessages = (await config.getFollowUpMessages?.()) || []; + if (followUpMessages.length > 0) { + // Set as pending so inner loop processes them + pendingMessages = followUpMessages; + continue; + } + + // No more messages, exit + break; + } + + await emit({ type: "agent_end", messages: newMessages }); +} + +/** + * Stream an assistant response from the LLM. + * This is where AgentMessage[] gets transformed to Message[] for the LLM. + */ +async function streamAssistantResponse( + context: AgentContext, + config: AgentLoopConfig, + signal: AbortSignal | undefined, + emit: AgentEventSink, + streamFn?: StreamFn, +): Promise<AssistantMessage> { + // Apply context transform if configured (AgentMessage[] → AgentMessage[]) + let messages = context.messages; + if (config.transformContext) { + messages = await config.transformContext(messages, signal); + } + + // Convert to LLM-compatible messages (AgentMessage[] → Message[]) + const llmMessages = await config.convertToLlm(messages); + + // Build LLM context + const llmContext: Context = { + systemPrompt: context.systemPrompt, + messages: llmMessages, + tools: context.tools, + }; + + const streamFunction = streamFn || streamSimple; + + // Resolve API key (important for expiring tokens) + const resolvedApiKey = + (config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey; + + const response = await streamFunction(config.model, llmContext, { + ...config, + apiKey: resolvedApiKey, + signal, + }); + + let partialMessage: AssistantMessage | null = null; + let addedPartial = false; + + for await (const event of response) { + switch (event.type) { + case "start": + partialMessage = event.partial; + context.messages.push(partialMessage); + addedPartial = true; + await emit({ type: "message_start", message: { ...partialMessage } }); + break; + + case "text_start": + case "text_delta": + case "text_end": + case "thinking_start": + case "thinking_delta": + case "thinking_end": + case "toolcall_start": + case "toolcall_delta": + case "toolcall_end": + if (partialMessage) { + partialMessage = event.partial; + context.messages[context.messages.length - 1] = partialMessage; + await emit({ + type: "message_update", + assistantMessageEvent: event, + message: { ...partialMessage }, + }); + } + break; + + case "done": + case "error": { + const finalMessage = await response.result(); + if (addedPartial) { + context.messages[context.messages.length - 1] = finalMessage; + } else { + context.messages.push(finalMessage); + } + if (!addedPartial) { + await emit({ type: "message_start", message: { ...finalMessage } }); + } + await emit({ type: "message_end", message: finalMessage }); + return finalMessage; + } + } + } + + const finalMessage = await response.result(); + if (addedPartial) { + context.messages[context.messages.length - 1] = finalMessage; + } else { + context.messages.push(finalMessage); + await emit({ type: "message_start", message: { ...finalMessage } }); + } + await emit({ type: "message_end", message: finalMessage }); + return finalMessage; +} + +/** + * Execute tool calls from an assistant message. + */ +async function executeToolCalls( + currentContext: AgentContext, + assistantMessage: AssistantMessage, + config: AgentLoopConfig, + signal: AbortSignal | undefined, + emit: AgentEventSink, +): Promise<ExecutedToolCallBatch> { + const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall"); + const hasSequentialToolCall = toolCalls.some( + (tc) => currentContext.tools?.find((t) => t.name === tc.name)?.executionMode === "sequential", + ); + if (config.toolExecution === "sequential" || hasSequentialToolCall) { + return executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, signal, emit); + } + return executeToolCallsParallel(currentContext, assistantMessage, toolCalls, config, signal, emit); +} + +type ExecutedToolCallBatch = { + messages: ToolResultMessage[]; + terminate: boolean; +}; + +async function executeToolCallsSequential( + currentContext: AgentContext, + assistantMessage: AssistantMessage, + toolCalls: AgentToolCall[], + config: AgentLoopConfig, + signal: AbortSignal | undefined, + emit: AgentEventSink, +): Promise<ExecutedToolCallBatch> { + const finalizedCalls: FinalizedToolCallOutcome[] = []; + const messages: ToolResultMessage[] = []; + + for (const toolCall of toolCalls) { + await emit({ + type: "tool_execution_start", + toolCallId: toolCall.id, + toolName: toolCall.name, + args: toolCall.arguments, + }); + + const preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal); + let finalized: FinalizedToolCallOutcome; + if (preparation.kind === "immediate") { + finalized = { + toolCall, + result: preparation.result, + isError: preparation.isError, + }; + } else { + const executed = await executePreparedToolCall(preparation, signal, emit); + finalized = await finalizeExecutedToolCall( + currentContext, + assistantMessage, + preparation, + executed, + config, + signal, + ); + } + + await emitToolExecutionEnd(finalized, emit); + const toolResultMessage = createToolResultMessage(finalized); + await emitToolResultMessage(toolResultMessage, emit); + finalizedCalls.push(finalized); + messages.push(toolResultMessage); + + if (signal?.aborted) { + break; + } + } + + return { + messages, + terminate: shouldTerminateToolBatch(finalizedCalls), + }; +} + +async function executeToolCallsParallel( + currentContext: AgentContext, + assistantMessage: AssistantMessage, + toolCalls: AgentToolCall[], + config: AgentLoopConfig, + signal: AbortSignal | undefined, + emit: AgentEventSink, +): Promise<ExecutedToolCallBatch> { + const finalizedCalls: FinalizedToolCallEntry[] = []; + + for (const toolCall of toolCalls) { + await emit({ + type: "tool_execution_start", + toolCallId: toolCall.id, + toolName: toolCall.name, + args: toolCall.arguments, + }); + + const preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal); + if (preparation.kind === "immediate") { + const finalized = { + toolCall, + result: preparation.result, + isError: preparation.isError, + } satisfies FinalizedToolCallOutcome; + await emitToolExecutionEnd(finalized, emit); + finalizedCalls.push(finalized); + if (signal?.aborted) { + break; + } + continue; + } + + finalizedCalls.push(async () => { + const executed = await executePreparedToolCall(preparation, signal, emit); + const finalized = await finalizeExecutedToolCall( + currentContext, + assistantMessage, + preparation, + executed, + config, + signal, + ); + await emitToolExecutionEnd(finalized, emit); + return finalized; + }); + if (signal?.aborted) { + break; + } + } + + const orderedFinalizedCalls = await Promise.all( + finalizedCalls.map((entry) => (typeof entry === "function" ? entry() : Promise.resolve(entry))), + ); + const messages: ToolResultMessage[] = []; + for (const finalized of orderedFinalizedCalls) { + const toolResultMessage = createToolResultMessage(finalized); + await emitToolResultMessage(toolResultMessage, emit); + messages.push(toolResultMessage); + } + + return { + messages, + terminate: shouldTerminateToolBatch(orderedFinalizedCalls), + }; +} + +type PreparedToolCall = { + kind: "prepared"; + toolCall: AgentToolCall; + tool: AgentTool<any>; + args: unknown; +}; + +type ImmediateToolCallOutcome = { + kind: "immediate"; + result: AgentToolResult<any>; + isError: boolean; +}; + +type ExecutedToolCallOutcome = { + result: AgentToolResult<any>; + isError: boolean; +}; + +type FinalizedToolCallOutcome = { + toolCall: AgentToolCall; + result: AgentToolResult<any>; + isError: boolean; +}; + +type FinalizedToolCallEntry = FinalizedToolCallOutcome | (() => Promise<FinalizedToolCallOutcome>); + +function shouldTerminateToolBatch(finalizedCalls: FinalizedToolCallOutcome[]): boolean { + return finalizedCalls.length > 0 && finalizedCalls.every((finalized) => finalized.result.terminate === true); +} + +function prepareToolCallArguments(tool: AgentTool<any>, toolCall: AgentToolCall): AgentToolCall { + if (!tool.prepareArguments) { + return toolCall; + } + const preparedArguments = tool.prepareArguments(toolCall.arguments); + if (preparedArguments === toolCall.arguments) { + return toolCall; + } + return { + ...toolCall, + arguments: preparedArguments as Record<string, any>, + }; +} + +async function prepareToolCall( + currentContext: AgentContext, + assistantMessage: AssistantMessage, + toolCall: AgentToolCall, + config: AgentLoopConfig, + signal: AbortSignal | undefined, +): Promise<PreparedToolCall | ImmediateToolCallOutcome> { + const tool = currentContext.tools?.find((t) => t.name === toolCall.name); + if (!tool) { + return { + kind: "immediate", + result: createErrorToolResult(`Tool ${toolCall.name} not found`), + isError: true, + }; + } + + try { + const preparedToolCall = prepareToolCallArguments(tool, toolCall); + const validatedArgs = validateToolArguments(tool, preparedToolCall); + if (config.beforeToolCall) { + const beforeResult = await config.beforeToolCall( + { + assistantMessage, + toolCall, + args: validatedArgs, + context: currentContext, + }, + signal, + ); + if (signal?.aborted) { + return { + kind: "immediate", + result: createErrorToolResult("Operation aborted"), + isError: true, + }; + } + if (beforeResult?.block) { + return { + kind: "immediate", + result: createErrorToolResult(beforeResult.reason || "Tool execution was blocked"), + isError: true, + }; + } + } + if (signal?.aborted) { + return { + kind: "immediate", + result: createErrorToolResult("Operation aborted"), + isError: true, + }; + } + return { + kind: "prepared", + toolCall, + tool, + args: validatedArgs, + }; + } catch (error) { + return { + kind: "immediate", + result: createErrorToolResult(error instanceof Error ? error.message : String(error)), + isError: true, + }; + } +} + +async function executePreparedToolCall( + prepared: PreparedToolCall, + signal: AbortSignal | undefined, + emit: AgentEventSink, +): Promise<ExecutedToolCallOutcome> { + const updateEvents: Promise<void>[] = []; + + try { + const result = await prepared.tool.execute( + prepared.toolCall.id, + prepared.args as never, + signal, + (partialResult) => { + updateEvents.push( + Promise.resolve( + emit({ + type: "tool_execution_update", + toolCallId: prepared.toolCall.id, + toolName: prepared.toolCall.name, + args: prepared.toolCall.arguments, + partialResult, + }), + ), + ); + }, + ); + await Promise.all(updateEvents); + return { result, isError: false }; + } catch (error) { + await Promise.all(updateEvents); + return { + result: createErrorToolResult(error instanceof Error ? error.message : String(error)), + isError: true, + }; + } +} + +async function finalizeExecutedToolCall( + currentContext: AgentContext, + assistantMessage: AssistantMessage, + prepared: PreparedToolCall, + executed: ExecutedToolCallOutcome, + config: AgentLoopConfig, + signal: AbortSignal | undefined, +): Promise<FinalizedToolCallOutcome> { + let result = executed.result; + let isError = executed.isError; + + if (config.afterToolCall) { + try { + const afterResult = await config.afterToolCall( + { + assistantMessage, + toolCall: prepared.toolCall, + args: prepared.args, + result, + isError, + context: currentContext, + }, + signal, + ); + if (afterResult) { + result = { + content: afterResult.content ?? result.content, + details: afterResult.details ?? result.details, + terminate: afterResult.terminate ?? result.terminate, + }; + isError = afterResult.isError ?? isError; + } + } catch (error) { + result = createErrorToolResult(error instanceof Error ? error.message : String(error)); + isError = true; + } + } + + return { + toolCall: prepared.toolCall, + result, + isError, + }; +} + +function createErrorToolResult(message: string): AgentToolResult<any> { + return { + content: [{ type: "text", text: message }], + details: {}, + }; +} + +async function emitToolExecutionEnd(finalized: FinalizedToolCallOutcome, emit: AgentEventSink): Promise<void> { + await emit({ + type: "tool_execution_end", + toolCallId: finalized.toolCall.id, + toolName: finalized.toolCall.name, + result: finalized.result, + isError: finalized.isError, + }); +} + +function createToolResultMessage(finalized: FinalizedToolCallOutcome): ToolResultMessage { + return { + role: "toolResult", + toolCallId: finalized.toolCall.id, + toolName: finalized.toolCall.name, + content: finalized.result.content, + details: finalized.result.details, + isError: finalized.isError, + timestamp: Date.now(), + }; +} + +async function emitToolResultMessage(toolResultMessage: ToolResultMessage, emit: AgentEventSink): Promise<void> { + await emit({ type: "message_start", message: toolResultMessage }); + await emit({ type: "message_end", message: toolResultMessage }); +} diff --git a/emain/agent/agent.ts b/emain/agent/agent.ts new file mode 100644 index 000000000..421d1ac72 --- /dev/null +++ b/emain/agent/agent.ts @@ -0,0 +1,557 @@ +import { + type ImageContent, + type Message, + type Model, + type SimpleStreamOptions, + streamSimple, + type TextContent, + type ThinkingBudgets, + type Transport, +} from "../ai"; +import { runAgentLoop, runAgentLoopContinue } from "./agent-loop"; +import type { + AfterToolCallContext, + AfterToolCallResult, + AgentContext, + AgentEvent, + AgentLoopConfig, + AgentLoopTurnUpdate, + AgentMessage, + AgentState, + AgentTool, + BeforeToolCallContext, + BeforeToolCallResult, + QueueMode, + StreamFn, + ToolExecutionMode, +} from "./types"; + +export type { QueueMode } from "./types"; + +function defaultConvertToLlm(messages: AgentMessage[]): Message[] { + return messages.filter( + (message) => message.role === "user" || message.role === "assistant" || message.role === "toolResult", + ); +} + +const EMPTY_USAGE = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +const DEFAULT_MODEL = { + id: "unknown", + name: "unknown", + api: "unknown", + provider: "unknown", + baseUrl: "", + reasoning: false, + input: [], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 0, + maxTokens: 0, +} satisfies Model<any>; + +type MutableAgentState = Omit<AgentState, "isStreaming" | "streamingMessage" | "pendingToolCalls" | "errorMessage"> & { + isStreaming: boolean; + streamingMessage?: AgentMessage; + pendingToolCalls: Set<string>; + errorMessage?: string; +}; + +function createMutableAgentState( + initialState?: Partial<Omit<AgentState, "pendingToolCalls" | "isStreaming" | "streamingMessage" | "errorMessage">>, +): MutableAgentState { + let tools = initialState?.tools?.slice() ?? []; + let messages = initialState?.messages?.slice() ?? []; + + return { + systemPrompt: initialState?.systemPrompt ?? "", + model: initialState?.model ?? DEFAULT_MODEL, + thinkingLevel: initialState?.thinkingLevel ?? "off", + get tools() { + return tools; + }, + set tools(nextTools: AgentTool<any>[]) { + tools = nextTools.slice(); + }, + get messages() { + return messages; + }, + set messages(nextMessages: AgentMessage[]) { + messages = nextMessages.slice(); + }, + isStreaming: false, + streamingMessage: undefined, + pendingToolCalls: new Set<string>(), + errorMessage: undefined, + }; +} + +/** Options for constructing an {@link Agent}. */ +export interface AgentOptions { + initialState?: Partial<Omit<AgentState, "pendingToolCalls" | "isStreaming" | "streamingMessage" | "errorMessage">>; + convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>; + transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>; + streamFn?: StreamFn; + getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined; + onPayload?: SimpleStreamOptions["onPayload"]; + onResponse?: SimpleStreamOptions["onResponse"]; + beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined>; + afterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise<AfterToolCallResult | undefined>; + prepareNextTurn?: ( + signal?: AbortSignal, + ) => Promise<AgentLoopTurnUpdate | undefined> | AgentLoopTurnUpdate | undefined; + steeringMode?: QueueMode; + followUpMode?: QueueMode; + sessionId?: string; + thinkingBudgets?: ThinkingBudgets; + transport?: Transport; + maxRetryDelayMs?: number; + toolExecution?: ToolExecutionMode; +} + +class PendingMessageQueue { + private messages: AgentMessage[] = []; + public mode: QueueMode; + + constructor(mode: QueueMode) { + this.mode = mode; + } + + enqueue(message: AgentMessage): void { + this.messages.push(message); + } + + hasItems(): boolean { + return this.messages.length > 0; + } + + drain(): AgentMessage[] { + if (this.mode === "all") { + const drained = this.messages.slice(); + this.messages = []; + return drained; + } + + const first = this.messages[0]; + if (!first) { + return []; + } + this.messages = this.messages.slice(1); + return [first]; + } + + clear(): void { + this.messages = []; + } +} + +type ActiveRun = { + promise: Promise<void>; + resolve: () => void; + abortController: AbortController; +}; + +/** + * Stateful wrapper around the low-level agent loop. + * + * `Agent` owns the current transcript, emits lifecycle events, executes tools, + * and exposes queueing APIs for steering and follow-up messages. + */ +export class Agent { + private _state: MutableAgentState; + private readonly listeners = new Set<(event: AgentEvent, signal: AbortSignal) => Promise<void> | void>(); + private readonly steeringQueue: PendingMessageQueue; + private readonly followUpQueue: PendingMessageQueue; + + public convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>; + public transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>; + public streamFn: StreamFn; + public getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined; + public onPayload?: SimpleStreamOptions["onPayload"]; + public onResponse?: SimpleStreamOptions["onResponse"]; + public beforeToolCall?: ( + context: BeforeToolCallContext, + signal?: AbortSignal, + ) => Promise<BeforeToolCallResult | undefined>; + public afterToolCall?: ( + context: AfterToolCallContext, + signal?: AbortSignal, + ) => Promise<AfterToolCallResult | undefined>; + public prepareNextTurn?: ( + signal?: AbortSignal, + ) => Promise<AgentLoopTurnUpdate | undefined> | AgentLoopTurnUpdate | undefined; + private activeRun?: ActiveRun; + /** Session identifier forwarded to providers for cache-aware backends. */ + public sessionId?: string; + /** Optional per-level thinking token budgets forwarded to the stream function. */ + public thinkingBudgets?: ThinkingBudgets; + /** Preferred transport forwarded to the stream function. */ + public transport: Transport; + /** Optional cap for provider-requested retry delays. */ + public maxRetryDelayMs?: number; + /** Tool execution strategy for assistant messages that contain multiple tool calls. */ + public toolExecution: ToolExecutionMode; + + constructor(options: AgentOptions = {}) { + this._state = createMutableAgentState(options.initialState); + this.convertToLlm = options.convertToLlm ?? defaultConvertToLlm; + this.transformContext = options.transformContext; + this.streamFn = options.streamFn ?? streamSimple; + this.getApiKey = options.getApiKey; + this.onPayload = options.onPayload; + this.onResponse = options.onResponse; + this.beforeToolCall = options.beforeToolCall; + this.afterToolCall = options.afterToolCall; + this.prepareNextTurn = options.prepareNextTurn; + this.steeringQueue = new PendingMessageQueue(options.steeringMode ?? "one-at-a-time"); + this.followUpQueue = new PendingMessageQueue(options.followUpMode ?? "one-at-a-time"); + this.sessionId = options.sessionId; + this.thinkingBudgets = options.thinkingBudgets; + this.transport = options.transport ?? "auto"; + this.maxRetryDelayMs = options.maxRetryDelayMs; + this.toolExecution = options.toolExecution ?? "parallel"; + } + + /** + * Subscribe to agent lifecycle events. + * + * Listener promises are awaited in subscription order and are included in + * the current run's settlement. Listeners also receive the active abort + * signal for the current run. + * + * `agent_end` is the final emitted event for a run, but the agent does not + * become idle until all awaited listeners for that event have settled. + */ + subscribe(listener: (event: AgentEvent, signal: AbortSignal) => Promise<void> | void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + /** + * Current agent state. + * + * Assigning `state.tools` or `state.messages` copies the provided top-level array. + */ + get state(): AgentState { + return this._state; + } + + /** Controls how queued steering messages are drained. */ + set steeringMode(mode: QueueMode) { + this.steeringQueue.mode = mode; + } + + get steeringMode(): QueueMode { + return this.steeringQueue.mode; + } + + /** Controls how queued follow-up messages are drained. */ + set followUpMode(mode: QueueMode) { + this.followUpQueue.mode = mode; + } + + get followUpMode(): QueueMode { + return this.followUpQueue.mode; + } + + /** Queue a message to be injected after the current assistant turn finishes. */ + steer(message: AgentMessage): void { + this.steeringQueue.enqueue(message); + } + + /** Queue a message to run only after the agent would otherwise stop. */ + followUp(message: AgentMessage): void { + this.followUpQueue.enqueue(message); + } + + /** Remove all queued steering messages. */ + clearSteeringQueue(): void { + this.steeringQueue.clear(); + } + + /** Remove all queued follow-up messages. */ + clearFollowUpQueue(): void { + this.followUpQueue.clear(); + } + + /** Remove all queued steering and follow-up messages. */ + clearAllQueues(): void { + this.clearSteeringQueue(); + this.clearFollowUpQueue(); + } + + /** Returns true when either queue still contains pending messages. */ + hasQueuedMessages(): boolean { + return this.steeringQueue.hasItems() || this.followUpQueue.hasItems(); + } + + /** Active abort signal for the current run, if any. */ + get signal(): AbortSignal | undefined { + return this.activeRun?.abortController.signal; + } + + /** Abort the current run, if one is active. */ + abort(): void { + this.activeRun?.abortController.abort(); + } + + /** + * Resolve when the current run and all awaited event listeners have finished. + * + * This resolves after `agent_end` listeners settle. + */ + waitForIdle(): Promise<void> { + return this.activeRun?.promise ?? Promise.resolve(); + } + + /** Clear transcript state, runtime state, and queued messages. */ + reset(): void { + this._state.messages = []; + this._state.isStreaming = false; + this._state.streamingMessage = undefined; + this._state.pendingToolCalls = new Set<string>(); + this._state.errorMessage = undefined; + this.clearFollowUpQueue(); + this.clearSteeringQueue(); + } + + /** Start a new prompt from text, a single message, or a batch of messages. */ + async prompt(message: AgentMessage | AgentMessage[]): Promise<void>; + async prompt(input: string, images?: ImageContent[]): Promise<void>; + async prompt(input: string | AgentMessage | AgentMessage[], images?: ImageContent[]): Promise<void> { + if (this.activeRun) { + throw new Error( + "Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion.", + ); + } + const messages = this.normalizePromptInput(input, images); + await this.runPromptMessages(messages); + } + + /** Continue from the current transcript. The last message must be a user or tool-result message. */ + async continue(): Promise<void> { + if (this.activeRun) { + throw new Error("Agent is already processing. Wait for completion before continuing."); + } + + const lastMessage = this._state.messages[this._state.messages.length - 1]; + if (!lastMessage) { + throw new Error("No messages to continue from"); + } + + if (lastMessage.role === "assistant") { + const queuedSteering = this.steeringQueue.drain(); + if (queuedSteering.length > 0) { + await this.runPromptMessages(queuedSteering, { skipInitialSteeringPoll: true }); + return; + } + + const queuedFollowUps = this.followUpQueue.drain(); + if (queuedFollowUps.length > 0) { + await this.runPromptMessages(queuedFollowUps); + return; + } + + throw new Error("Cannot continue from message role: assistant"); + } + + await this.runContinuation(); + } + + private normalizePromptInput( + input: string | AgentMessage | AgentMessage[], + images?: ImageContent[], + ): AgentMessage[] { + if (Array.isArray(input)) { + return input; + } + + if (typeof input !== "string") { + return [input]; + } + + const content: Array<TextContent | ImageContent> = [{ type: "text", text: input }]; + if (images && images.length > 0) { + content.push(...images); + } + return [{ role: "user", content, timestamp: Date.now() }]; + } + + private async runPromptMessages( + messages: AgentMessage[], + options: { skipInitialSteeringPoll?: boolean } = {}, + ): Promise<void> { + await this.runWithLifecycle(async (signal) => { + await runAgentLoop( + messages, + this.createContextSnapshot(), + this.createLoopConfig(options), + (event) => this.processEvents(event), + signal, + this.streamFn, + ); + }); + } + + private async runContinuation(): Promise<void> { + await this.runWithLifecycle(async (signal) => { + await runAgentLoopContinue( + this.createContextSnapshot(), + this.createLoopConfig(), + (event) => this.processEvents(event), + signal, + this.streamFn, + ); + }); + } + + private createContextSnapshot(): AgentContext { + return { + systemPrompt: this._state.systemPrompt, + messages: this._state.messages.slice(), + tools: this._state.tools.slice(), + }; + } + + private createLoopConfig(options: { skipInitialSteeringPoll?: boolean } = {}): AgentLoopConfig { + let skipInitialSteeringPoll = options.skipInitialSteeringPoll === true; + return { + model: this._state.model, + reasoning: this._state.thinkingLevel === "off" ? undefined : this._state.thinkingLevel, + sessionId: this.sessionId, + onPayload: this.onPayload, + onResponse: this.onResponse, + transport: this.transport, + thinkingBudgets: this.thinkingBudgets, + maxRetryDelayMs: this.maxRetryDelayMs, + toolExecution: this.toolExecution, + beforeToolCall: this.beforeToolCall, + afterToolCall: this.afterToolCall, + prepareNextTurn: this.prepareNextTurn ? async () => await this.prepareNextTurn?.(this.signal) : undefined, + convertToLlm: this.convertToLlm, + transformContext: this.transformContext, + getApiKey: this.getApiKey, + getSteeringMessages: async () => { + if (skipInitialSteeringPoll) { + skipInitialSteeringPoll = false; + return []; + } + return this.steeringQueue.drain(); + }, + getFollowUpMessages: async () => this.followUpQueue.drain(), + }; + } + + private async runWithLifecycle(executor: (signal: AbortSignal) => Promise<void>): Promise<void> { + if (this.activeRun) { + throw new Error("Agent is already processing."); + } + + const abortController = new AbortController(); + let resolvePromise = () => {}; + const promise = new Promise<void>((resolve) => { + resolvePromise = resolve; + }); + this.activeRun = { promise, resolve: resolvePromise, abortController }; + + this._state.isStreaming = true; + this._state.streamingMessage = undefined; + this._state.errorMessage = undefined; + + try { + await executor(abortController.signal); + } catch (error) { + await this.handleRunFailure(error, abortController.signal.aborted); + } finally { + this.finishRun(); + } + } + + private async handleRunFailure(error: unknown, aborted: boolean): Promise<void> { + const failureMessage = { + role: "assistant", + content: [{ type: "text", text: "" }], + api: this._state.model.api, + provider: this._state.model.provider, + model: this._state.model.id, + usage: EMPTY_USAGE, + stopReason: aborted ? "aborted" : "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp: Date.now(), + } satisfies AgentMessage; + await this.processEvents({ type: "message_start", message: failureMessage }); + await this.processEvents({ type: "message_end", message: failureMessage }); + await this.processEvents({ type: "turn_end", message: failureMessage, toolResults: [] }); + await this.processEvents({ type: "agent_end", messages: [failureMessage] }); + } + + private finishRun(): void { + this._state.isStreaming = false; + this._state.streamingMessage = undefined; + this._state.pendingToolCalls = new Set<string>(); + this.activeRun?.resolve(); + this.activeRun = undefined; + } + + /** + * Reduce internal state for a loop event, then await listeners. + * + * `agent_end` only means no further loop events will be emitted. The run is + * considered idle later, after all awaited listeners for `agent_end` finish + * and `finishRun()` clears runtime-owned state. + */ + private async processEvents(event: AgentEvent): Promise<void> { + switch (event.type) { + case "message_start": + this._state.streamingMessage = event.message; + break; + + case "message_update": + this._state.streamingMessage = event.message; + break; + + case "message_end": + this._state.streamingMessage = undefined; + this._state.messages.push(event.message); + break; + + case "tool_execution_start": { + const pendingToolCalls = new Set(this._state.pendingToolCalls); + pendingToolCalls.add(event.toolCallId); + this._state.pendingToolCalls = pendingToolCalls; + break; + } + + case "tool_execution_end": { + const pendingToolCalls = new Set(this._state.pendingToolCalls); + pendingToolCalls.delete(event.toolCallId); + this._state.pendingToolCalls = pendingToolCalls; + break; + } + + case "turn_end": + if (event.message.role === "assistant" && event.message.errorMessage) { + this._state.errorMessage = event.message.errorMessage; + } + break; + + case "agent_end": + this._state.streamingMessage = undefined; + break; + } + + const signal = this.activeRun?.abortController.signal; + if (!signal) { + throw new Error("Agent listener invoked outside active run"); + } + for (const listener of this.listeners) { + await listener(event, signal); + } + } +} diff --git a/emain/agent/build-system-prompt.ts b/emain/agent/build-system-prompt.ts new file mode 100644 index 000000000..1cf7cf56f --- /dev/null +++ b/emain/agent/build-system-prompt.ts @@ -0,0 +1,40 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +// +// build-system-prompt.ts — composes the system prompt fed to the LLM +// for a given pane invocation. Called per turn (via the function-form +// systemPrompt option on AgentHarness) so cwd / git / recent-cmds +// updates between sends are reflected immediately. See +// docs/agent-runtime-architecture.md §5.3 / §5.4. +// +// Kept separate from pi's harness/system-prompt.ts because pi's helper +// targets its standalone CLI's context surface; crest's surface is +// terminal-pane state, which is different enough that adapters through +// pi's helper would be more code than just writing our own composer. + +const BASE_INSTRUCTIONS = `You are an AI assistant integrated into crest, a modern terminal. +Help the user with terminal-centric coding tasks: running shell commands, reading and writing files, navigating their project. +Prefer concise answers. When uncertain, ask one clarifying question rather than guessing.`; + +export interface SystemPromptInputs { + /** Pane's current working directory (absolute path). */ + cwd: string; + /** Active git branch in cwd, if any. Omitted when not a git repo. */ + gitBranch?: string; + /** Connection name when the pane is on a remote SSH host. "local" is skipped. */ + connection?: string; + /** Last few commands the user ran in this pane, oldest → newest. Capped to 5 in the rendered prompt. */ + recentCmds?: string[]; +} + +export function buildSystemPrompt(inputs: SystemPromptInputs): string { + const lines: string[] = [BASE_INSTRUCTIONS, "", "## Pane context", `- cwd: ${inputs.cwd}`]; + if (inputs.gitBranch) lines.push(`- git branch: ${inputs.gitBranch}`); + if (inputs.connection && inputs.connection !== "local") { + lines.push(`- connection: ${inputs.connection}`); + } + if (inputs.recentCmds && inputs.recentCmds.length > 0) { + lines.push("", "## Recent commands", ...inputs.recentCmds.slice(-5).map((c) => `- ${c}`)); + } + return lines.join("\n"); +} diff --git a/emain/agent/eval/providers.ts b/emain/agent/eval/providers.ts new file mode 100644 index 000000000..6c9182076 --- /dev/null +++ b/emain/agent/eval/providers.ts @@ -0,0 +1,98 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +// +// Provider matrix for the agent regression harness. Each entry knows how +// to build a pi-ai Model for a cheap, tool-capable model on that provider +// and which env var carries the API key. +// +// Anthropic / OpenAI / Google models come straight from pi-ai's registry +// (getModel). OpenRouter free-tier models aren't enumerated in the +// registry, so we hand-build the Model shape the same way the live +// catalog path does (openai-completions api, baseUrl override). + +import type { Api, Model } from "../../ai"; +import { getModel } from "../../ai"; + +export interface ProviderConfig { + id: string; + // Env var that must be set for this provider to run. The model id can + // be overridden via <ID>_MODEL (e.g. ANTHROPIC_MODEL). + envKey: string; + modelEnvKey: string; + defaultModel: string; + buildModel(modelId: string): Model<Api>; +} + +function registryModel(provider: string, modelId: string): Model<Api> { + // getModel is typed against literal registry keys; our runtime strings + // can't satisfy that, so cast. Returns undefined for unknown ids — + // caller throws a clear error in that case. + const model = (getModel as unknown as (p: string, m: string) => Model<Api> | undefined)( + provider, + modelId, + ); + if (!model) { + throw new Error(`regression: model "${provider}/${modelId}" not in pi-ai registry`); + } + return model; +} + +function openRouterModel(modelId: string): Model<Api> { + return { + id: modelId, + name: modelId, + api: "openai-completions", + provider: "openrouter", + // openai SDK appends /chat/completions itself — base path only. + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 4_096, + }; +} + +export const PROVIDERS: ProviderConfig[] = [ + { + id: "anthropic", + envKey: "ANTHROPIC_API_KEY", + modelEnvKey: "ANTHROPIC_MODEL", + defaultModel: "claude-haiku-4-5", + buildModel: (m) => registryModel("anthropic", m), + }, + { + id: "openai", + envKey: "OPENAI_API_KEY", + modelEnvKey: "OPENAI_MODEL", + defaultModel: "gpt-4o-mini", + buildModel: (m) => registryModel("openai", m), + }, + { + id: "google", + envKey: "GEMINI_API_KEY", + modelEnvKey: "GOOGLE_MODEL", + defaultModel: "gemini-2.0-flash", + buildModel: (m) => registryModel("google", m), + }, + { + id: "openrouter", + envKey: "OPENROUTER_API_KEY", + modelEnvKey: "OPENROUTER_MODEL", + // gpt-oss-20b:free reliably handles tool calls and is less + // aggressively rate-limited than llama-3.3-70b:free (which + // returns upstream 429s under light load). Override via + // OPENROUTER_MODEL. + defaultModel: "openai/gpt-oss-20b:free", + buildModel: openRouterModel, + }, +]; + +export function resolveModelId(p: ProviderConfig): string { + return process.env[p.modelEnvKey] ?? p.defaultModel; +} + +export function apiKeyFor(p: ProviderConfig): string | undefined { + const v = process.env[p.envKey]; + return v && v.trim() ? v.trim() : undefined; +} diff --git a/emain/agent/eval/run-regression.ts b/emain/agent/eval/run-regression.ts new file mode 100644 index 000000000..f1047b2f2 --- /dev/null +++ b/emain/agent/eval/run-regression.ts @@ -0,0 +1,222 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +// +// run-regression.ts — repeatable E2E regression for the integrated agent +// runtime. Runs a fixed scenario matrix (see scenarios.ts) against every +// provider in the matrix (see providers.ts) that has an API key set in +// the environment. Replaces the one-off _test-openrouter.ts. +// +// Usage: +// ANTHROPIC_API_KEY=... OPENAI_API_KEY=... \ +// GEMINI_API_KEY=... OPENROUTER_API_KEY=... \ +// npx tsx emain/agent/eval/run-regression.ts +// +// # restrict to specific providers: +// ONLY=openrouter,anthropic npx tsx emain/agent/eval/run-regression.ts +// +// # restrict to specific scenarios: +// SCENARIOS=text-only,list-dir npx tsx emain/agent/eval/run-regression.ts +// +// Providers with no key are skipped (not failed). Exit code is non-zero if +// any executed scenario failed, so CI can gate on it once keys are wired +// into secrets. + +import { promises as fs } from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { AgentHarness } from "../harness/agent-harness"; +import { JsonlSessionRepo } from "../harness/session/jsonl-repo"; +import { NodeExecutionEnv } from "../node"; +import { buildSystemPrompt } from "../build-system-prompt"; +import { getDefaultTools } from "../tools"; +import type { AgentEvent } from "../types"; +import { apiKeyFor, PROVIDERS, resolveModelId, type ProviderConfig } from "./providers"; +import { SCENARIOS, type RunCapture, type Scenario } from "./scenarios"; + +const PER_SCENARIO_TIMEOUT_MS = 90_000; + +interface ScenarioResult { + provider: string; + model: string; + scenario: string; + ok: boolean; + failures: string[]; + durationMs: number; + toolCalls: string[]; + skipped?: boolean; + error?: string; +} + +function csvEnv(name: string): Set<string> | null { + const raw = process.env[name]; + if (!raw) return null; + return new Set(raw.split(",").map((s) => s.trim()).filter(Boolean)); +} + +async function runScenario( + provider: ProviderConfig, + modelId: string, + apiKey: string, + scenario: Scenario, + cwd: string, + sessionsRoot: string, +): Promise<ScenarioResult> { + const start = Date.now(); + const env = new NodeExecutionEnv({ cwd }); + const repoEnv = new NodeExecutionEnv({ cwd }); + const repo = new JsonlSessionRepo({ fs: repoEnv, sessionsRoot }); + const session = await repo.create({ cwd }); + + const cap: RunCapture = { + toolCalls: [], + toolResults: [], + finalText: "", + turnCount: 0, + }; + + const harness = new AgentHarness({ + env, + session, + model: provider.buildModel(modelId), + thinkingLevel: "off", + tools: getDefaultTools(), + systemPrompt: () => buildSystemPrompt({ cwd, recentCmds: [] }), + getApiKeyAndHeaders: async () => ({ apiKey }), + }); + + harness.subscribe((event: AgentEvent) => { + switch (event.type) { + case "tool_execution_start": + cap.toolCalls.push({ name: event.toolName, args: event.args }); + break; + case "tool_execution_end": + cap.toolResults.push({ name: event.toolName, isError: event.isError }); + break; + case "turn_end": + cap.turnCount += 1; + break; + default: + break; + } + }); + + const prompt = scenario.prompt.replaceAll("{cwd}", cwd); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + void harness.abort(); + }, PER_SCENARIO_TIMEOUT_MS); + + try { + const final = await harness.prompt(prompt); + clearTimeout(timer); + cap.stopReason = (final as { stopReason?: string }).stopReason; + cap.errorMessage = (final as { errorMessage?: string }).errorMessage; + // Final assistant text = concatenated text content blocks. + cap.finalText = (final.content ?? []) + .filter((c): c is { type: "text"; text: string } => (c as { type?: string }).type === "text") + .map((c) => c.text) + .join(""); + if (timedOut) { + return resultOf(provider, modelId, scenario, start, cap, [ + `scenario timed out after ${PER_SCENARIO_TIMEOUT_MS}ms`, + ]); + } + const failures = scenario.check(cap); + return resultOf(provider, modelId, scenario, start, cap, failures); + } catch (err) { + clearTimeout(timer); + return { + provider: provider.id, + model: modelId, + scenario: scenario.id, + ok: false, + failures: [`threw: ${err instanceof Error ? err.message : String(err)}`], + durationMs: Date.now() - start, + toolCalls: cap.toolCalls.map((c) => c.name), + error: err instanceof Error ? err.message : String(err), + }; + } +} + +function resultOf( + provider: ProviderConfig, + modelId: string, + scenario: Scenario, + start: number, + cap: RunCapture, + failures: string[], +): ScenarioResult { + return { + provider: provider.id, + model: modelId, + scenario: scenario.id, + ok: failures.length === 0, + failures, + durationMs: Date.now() - start, + toolCalls: cap.toolCalls.map((c) => c.name), + }; +} + +async function main(): Promise<void> { + const onlyProviders = csvEnv("ONLY"); + const onlyScenarios = csvEnv("SCENARIOS"); + const cwd = process.cwd(); + const sessionsRoot = await fs.mkdtemp(path.join(os.tmpdir(), "crest-regression-")); + + const scenarios = SCENARIOS.filter((s) => !onlyScenarios || onlyScenarios.has(s.id)); + const providers = PROVIDERS.filter((p) => !onlyProviders || onlyProviders.has(p.id)); + + console.log(`[regression] cwd=${cwd}`); + console.log(`[regression] sessions=${sessionsRoot}`); + console.log(`[regression] scenarios: ${scenarios.map((s) => s.id).join(", ")}`); + + const results: ScenarioResult[] = []; + const skippedProviders: string[] = []; + + for (const provider of providers) { + const apiKey = apiKeyFor(provider); + if (!apiKey) { + skippedProviders.push(provider.id); + continue; + } + const modelId = resolveModelId(provider); + console.log(`\n[regression] === ${provider.id} (${modelId}) ===`); + for (const scenario of scenarios) { + process.stdout.write(` ${scenario.id} ... `); + const r = await runScenario(provider, modelId, apiKey, scenario, cwd, sessionsRoot); + results.push(r); + if (r.ok) { + console.log(`PASS (${r.durationMs}ms, tools: ${r.toolCalls.join(",") || "none"})`); + } else { + console.log(`FAIL (${r.durationMs}ms)`); + for (const f of r.failures) console.log(` - ${f}`); + } + } + } + + console.log("\n[regression] ───────── summary ─────────"); + if (skippedProviders.length) { + console.log(`[regression] skipped (no API key): ${skippedProviders.join(", ")}`); + } + const executed = results.length; + const passed = results.filter((r) => r.ok).length; + const failed = executed - passed; + console.log(`[regression] ${passed}/${executed} scenarios passed across ${providers.length - skippedProviders.length} provider(s)`); + + if (executed === 0) { + console.log("[regression] nothing ran — set at least one provider API key."); + process.exit(2); + } + if (failed > 0) { + console.log(`[regression] ${failed} failed`); + process.exit(1); + } + console.log("[regression] all green"); +} + +void main().catch((err) => { + console.error("[regression] FATAL:", err); + process.exit(1); +}); diff --git a/emain/agent/eval/scenarios.test.ts b/emain/agent/eval/scenarios.test.ts new file mode 100644 index 000000000..099c8c9ee --- /dev/null +++ b/emain/agent/eval/scenarios.test.ts @@ -0,0 +1,121 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +// +// Offline tests for the regression-harness assertion logic. The live +// runner (run-regression.ts) needs API keys and a network, so it can't +// run in CI — but the *check* functions in scenarios.ts are pure over a +// RunCapture and must be correct, since a buggy check silently passes a +// broken agent. These tests pin that logic. + +import { describe, expect, it } from "vitest"; + +import { SCENARIOS, type RunCapture } from "./scenarios"; + +function baseCapture(over: Partial<RunCapture> = {}): RunCapture { + return { + toolCalls: [], + toolResults: [], + finalText: "", + stopReason: "stop", + turnCount: 1, + ...over, + }; +} + +function scenario(id: string) { + const s = SCENARIOS.find((x) => x.id === id); + if (!s) throw new Error(`no scenario "${id}"`); + return s; +} + +describe("regression scenario checks", () => { + describe("text-only", () => { + const s = scenario("text-only"); + it("passes on a clean OK with no tools", () => { + expect(s.check(baseCapture({ finalText: "OK" }))).toEqual([]); + }); + it("fails if any tool was called", () => { + const fails = s.check( + baseCapture({ finalText: "OK", toolCalls: [{ name: "list_dir", args: {} }] }), + ); + expect(fails.length).toBeGreaterThan(0); + }); + it("fails if the text doesn't contain OK", () => { + expect(s.check(baseCapture({ finalText: "sure thing" })).length).toBeGreaterThan(0); + }); + it("fails on an errored stop reason", () => { + const fails = s.check( + baseCapture({ finalText: "OK", stopReason: "error", errorMessage: "boom" }), + ); + expect(fails.some((f) => f.includes("boom"))).toBe(true); + }); + }); + + describe("list-dir", () => { + const s = scenario("list-dir"); + it("passes when list_dir was called without error", () => { + expect( + s.check(baseCapture({ toolCalls: [{ name: "list_dir", args: {} }], toolResults: [{ name: "list_dir", isError: false }] })), + ).toEqual([]); + }); + it("fails when list_dir was never called", () => { + expect(s.check(baseCapture()).length).toBeGreaterThan(0); + }); + it("fails when the tool returned an error result", () => { + const fails = s.check( + baseCapture({ toolCalls: [{ name: "list_dir", args: {} }], toolResults: [{ name: "list_dir", isError: true }] }), + ); + expect(fails.some((f) => f.includes("error result"))).toBe(true); + }); + }); + + describe("read-file", () => { + const s = scenario("read-file"); + it("passes when read_file ran and text mentions crest", () => { + expect( + s.check(baseCapture({ toolCalls: [{ name: "read_file", args: {} }], finalText: 'the name is "crest"' })), + ).toEqual([]); + }); + it("fails when read_file ran but name not mentioned", () => { + const fails = s.check( + baseCapture({ toolCalls: [{ name: "read_file", args: {} }], finalText: "it is a package" }), + ); + expect(fails.length).toBeGreaterThan(0); + }); + }); + + describe("shell-exec", () => { + const s = scenario("shell-exec"); + it("passes when shell_exec ran and marker echoed", () => { + expect( + s.check(baseCapture({ toolCalls: [{ name: "shell_exec", args: {} }], finalText: "output was regression-marker-42" })), + ).toEqual([]); + }); + it("fails when marker is missing", () => { + expect( + s.check(baseCapture({ toolCalls: [{ name: "shell_exec", args: {} }], finalText: "done" })).length, + ).toBeGreaterThan(0); + }); + }); + + describe("multi-step", () => { + const s = scenario("multi-step"); + it("passes with >=2 tool calls including read_file", () => { + expect( + s.check( + baseCapture({ + toolCalls: [ + { name: "list_dir", args: {} }, + { name: "read_file", args: {} }, + ], + }), + ), + ).toEqual([]); + }); + it("fails with a single tool call", () => { + expect( + s.check(baseCapture({ toolCalls: [{ name: "list_dir", args: {} }] })).length, + ).toBeGreaterThan(0); + }); + }); +}); diff --git a/emain/agent/eval/scenarios.ts b/emain/agent/eval/scenarios.ts new file mode 100644 index 000000000..a9fa6ae06 --- /dev/null +++ b/emain/agent/eval/scenarios.ts @@ -0,0 +1,116 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +// +// Representative scenarios for the agent regression harness. Each scenario +// is a prompt plus a `check` over what the agent actually did (tool calls, +// tool results, final text, stop reason). LLM output is non-deterministic, +// so checks assert on *behavior* (did it call list_dir? did the final text +// mention the marker?) rather than exact strings. + +export interface RunCapture { + toolCalls: { name: string; args: unknown }[]; + toolResults: { name: string; isError: boolean }[]; + finalText: string; + stopReason?: string; + errorMessage?: string; + turnCount: number; +} + +export interface Scenario { + id: string; + // {cwd} is substituted with the run's working directory. + prompt: string; + // Returns a list of failure messages. Empty array = pass. + check: (cap: RunCapture) => string[]; +} + +function calledTool(cap: RunCapture, name: string): boolean { + return cap.toolCalls.some((c) => c.name === name); +} + +function noToolErrors(cap: RunCapture): string[] { + const errs = cap.toolResults.filter((r) => r.isError); + return errs.map((e) => `tool "${e.name}" returned an error result`); +} + +function notErrored(cap: RunCapture): string[] { + if (cap.stopReason === "error") { + return [`agent stopped with error: ${cap.errorMessage ?? "(no message)"}`]; + } + return []; +} + +function textIncludes(cap: RunCapture, needle: string): boolean { + return cap.finalText.toLowerCase().includes(needle.toLowerCase()); +} + +export const SCENARIOS: Scenario[] = [ + { + id: "text-only", + prompt: "Reply with exactly the word OK and nothing else. Do not call any tools.", + check: (cap) => { + const fails = notErrored(cap); + if (cap.toolCalls.length > 0) { + fails.push(`expected no tool calls, got ${cap.toolCalls.length}`); + } + if (!textIncludes(cap, "ok")) { + fails.push(`expected final text to contain "OK", got: ${JSON.stringify(cap.finalText.slice(0, 120))}`); + } + return fails; + }, + }, + { + id: "list-dir", + prompt: "List the files and directories in {cwd} using the list_dir tool, then briefly summarize what you found.", + check: (cap) => { + const fails = [...notErrored(cap), ...noToolErrors(cap)]; + if (!calledTool(cap, "list_dir")) { + fails.push(`expected list_dir to be called; tools called: ${cap.toolCalls.map((c) => c.name).join(", ") || "(none)"}`); + } + return fails; + }, + }, + { + id: "read-file", + prompt: 'Read the file {cwd}/package.json with the read_file tool and tell me the exact value of its top-level "name" field.', + check: (cap) => { + const fails = [...notErrored(cap), ...noToolErrors(cap)]; + if (!calledTool(cap, "read_file")) { + fails.push(`expected read_file to be called; tools called: ${cap.toolCalls.map((c) => c.name).join(", ") || "(none)"}`); + } + // package.json name is "crest". + if (!textIncludes(cap, "crest")) { + fails.push(`expected final text to mention the package name "crest", got: ${JSON.stringify(cap.finalText.slice(0, 160))}`); + } + return fails; + }, + }, + { + id: "shell-exec", + prompt: "Run the shell command: echo regression-marker-42 — using the shell_exec tool, and report its exact stdout.", + check: (cap) => { + const fails = [...notErrored(cap), ...noToolErrors(cap)]; + if (!calledTool(cap, "shell_exec")) { + fails.push(`expected shell_exec to be called; tools called: ${cap.toolCalls.map((c) => c.name).join(", ") || "(none)"}`); + } + if (!textIncludes(cap, "regression-marker-42")) { + fails.push(`expected final text to echo "regression-marker-42", got: ${JSON.stringify(cap.finalText.slice(0, 160))}`); + } + return fails; + }, + }, + { + id: "multi-step", + prompt: "First list the files in {cwd}, then read the package.json you find there and report its \"name\" field. Use the tools.", + check: (cap) => { + const fails = [...notErrored(cap), ...noToolErrors(cap)]; + if (cap.toolCalls.length < 2) { + fails.push(`expected at least 2 tool calls (list then read), got ${cap.toolCalls.length}: ${cap.toolCalls.map((c) => c.name).join(", ")}`); + } + if (!calledTool(cap, "read_file")) { + fails.push("expected read_file among the tool calls"); + } + return fails; + }, + }, +]; diff --git a/emain/agent/harness-factory.ts b/emain/agent/harness-factory.ts new file mode 100644 index 000000000..26b12317a --- /dev/null +++ b/emain/agent/harness-factory.ts @@ -0,0 +1,87 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +// +// harness-factory.ts — assembles a PaneHarness for one pane's agent +// session. PaneHarness is a minimal adapter (intentionally not a +// "runtime wrapper") that exposes the env mutation seam pi otherwise +// leaves implicit. See docs/agent-runtime-architecture.md §5.4 / §7.4. +// +// All non-env behavior — subscribe, prompt, abort, message storage, +// model swap, queue management — is direct AgentHarness usage. The +// IPC layer (task #9) holds a Map<sessionPath, PaneHarness> and uses +// it directly without wrapping further. + +import type { Api, Model } from "../ai"; +import { AgentHarness } from "./harness/agent-harness"; +import type { Session } from "./harness/types"; +import { NodeExecutionEnv } from "./node"; +import type { ToolCallHook } from "./permissions"; +import type { AgentTool, ThinkingLevel } from "./types"; +import { buildSystemPrompt, type SystemPromptInputs } from "./build-system-prompt"; + +export interface BuildPaneHarnessOptions { + /** Session this pane is bound to. Mint via createPaneSession() first. */ + session: Session; + /** Resolved model from the frontend ai-resolver, threaded through IPC. */ + model: Model<Api>; + /** Reasoning effort, when the model supports it. */ + thinkingLevel?: ThinkingLevel; + /** Initial pane context — cwd / git / recent cmds. Mutable via update(). */ + promptInputs: SystemPromptInputs; + /** Tool definitions. Empty until task #10 wires the crest tools. */ + tools?: AgentTool[]; + /** + * Optional tool_call gate (typically from buildPermissionsHook). + * AgentHarness invokes this before executing every tool call. + * Returning undefined allows; {block: true, reason} denies and + * surfaces `reason` as the tool result text (visible inline in + * the agent block). See emain/agent/permissions.ts and + * docs/agent-runtime-architecture.md §7.9. + */ + toolCallHook?: ToolCallHook; +} + +export interface PaneHarness { + /** The underlying pi AgentHarness. Use directly for subscribe/prompt/abort. */ + readonly harness: AgentHarness; + /** + * Refresh pane state. Mutates the harness's env.cwd (so tool + * execution targets the latest dir) and the system-prompt input + * closure (so the next turn's prompt reflects the new cwd / git / + * recent commands). Cheap — just two assignments. Call before + * each send if any pane state changed. + */ + update(inputs: SystemPromptInputs): void; +} + +export function buildPaneHarness(opts: BuildPaneHarnessOptions): PaneHarness { + let inputs: SystemPromptInputs = opts.promptInputs; + // env.cwd is publicly mutable on NodeExecutionEnv (harness/env/nodejs.ts:218); + // we keep one env for the harness's lifetime and mutate it in place. + const env = new NodeExecutionEnv({ cwd: inputs.cwd }); + const harness = new AgentHarness({ + env, + session: opts.session, + model: opts.model, + thinkingLevel: opts.thinkingLevel ?? "off", + tools: opts.tools ?? [], + // Function form so each turn re-reads the latest inputs closure. + // The harness invokes this once per LLM call, not once per harness + // construction — picker model changes mid-conversation also work + // because pi's AgentHarness threads this through every turn. + systemPrompt: () => buildSystemPrompt(inputs), + }); + if (opts.toolCallHook) { + // AgentHarness gates tool execution via the typed "tool_call" + // event hook; .on() returns an unsubscribe we ignore because + // the harness lifetime IS the hook lifetime. + harness.on("tool_call", opts.toolCallHook); + } + return { + harness, + update(next: SystemPromptInputs): void { + inputs = next; + env.cwd = next.cwd; + }, + }; +} diff --git a/emain/agent/harness/agent-harness.ts b/emain/agent/harness/agent-harness.ts new file mode 100644 index 000000000..372d50df8 --- /dev/null +++ b/emain/agent/harness/agent-harness.ts @@ -0,0 +1,995 @@ +import { + type AssistantMessage, + type ImageContent, + type Model, + streamSimple, + type UserMessage, +} from "../../ai"; +import { runAgentLoop } from "../agent-loop"; +import type { + AgentContext, + AgentEvent, + AgentLoopConfig, + AgentMessage, + AgentTool, + QueueMode, + StreamFn, + ThinkingLevel, +} from "../types"; +import { collectEntriesForBranchSummary, generateBranchSummary } from "./compaction/branch-summarization"; +import { compact, DEFAULT_COMPACTION_SETTINGS, prepareCompaction } from "./compaction/compaction"; +import { convertToLlm } from "./messages"; +import { formatPromptTemplateInvocation } from "./prompt-templates"; +import { formatSkillInvocation } from "./skills"; +import type { + AbortResult, + AgentHarnessEvent, + AgentHarnessEventResultMap, + AgentHarnessOptions, + AgentHarnessOwnEvent, + AgentHarnessPhase, + AgentHarnessResources, + AgentHarnessStreamOptions, + AgentHarnessStreamOptionsPatch, + ExecutionEnv, + NavigateTreeResult, + PendingSessionWrite, + PromptTemplate, + Session, + Skill, +} from "./types"; +import { AgentHarnessError, BranchSummaryError, CompactionError, SessionError, toError } from "./types"; + +function createUserMessage(text: string, images?: ImageContent[]): UserMessage { + const content: Array<{ type: "text"; text: string } | ImageContent> = [{ type: "text", text }]; + if (images) content.push(...images); + return { role: "user", content, timestamp: Date.now() }; +} + +function createFailureMessage(model: Model<any>, error: unknown, aborted: boolean): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text: "" }], + api: model.api, + provider: model.provider, + model: model.id, + stopReason: aborted ? "aborted" : "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp: Date.now(), + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }; +} + +function cloneStreamOptions(streamOptions?: AgentHarnessStreamOptions): AgentHarnessStreamOptions { + return { + ...streamOptions, + headers: streamOptions?.headers ? { ...streamOptions.headers } : undefined, + metadata: streamOptions?.metadata ? { ...streamOptions.metadata } : undefined, + }; +} + +function mergeHeaders(...headers: Array<Record<string, string> | undefined>): Record<string, string> | undefined { + const merged: Record<string, string> = {}; + let hasHeaders = false; + for (const entry of headers) { + if (!entry) continue; + Object.assign(merged, entry); + hasHeaders = true; + } + return hasHeaders ? merged : undefined; +} + +function applyStreamOptionsPatch( + base: AgentHarnessStreamOptions, + patch?: AgentHarnessStreamOptionsPatch, +): AgentHarnessStreamOptions { + const result = cloneStreamOptions(base); + if (!patch) return result; + + if (Object.hasOwn(patch, "transport")) result.transport = patch.transport; + if (Object.hasOwn(patch, "timeoutMs")) result.timeoutMs = patch.timeoutMs; + if (Object.hasOwn(patch, "maxRetries")) result.maxRetries = patch.maxRetries; + if (Object.hasOwn(patch, "maxRetryDelayMs")) result.maxRetryDelayMs = patch.maxRetryDelayMs; + if (Object.hasOwn(patch, "cacheRetention")) result.cacheRetention = patch.cacheRetention; + + if (Object.hasOwn(patch, "headers")) { + if (patch.headers === undefined) { + result.headers = undefined; + } else { + const headers = { ...(result.headers ?? {}) }; + for (const [key, value] of Object.entries(patch.headers)) { + if (value === undefined) delete headers[key]; + else headers[key] = value; + } + result.headers = Object.keys(headers).length > 0 ? headers : undefined; + } + } + + if (Object.hasOwn(patch, "metadata")) { + if (patch.metadata === undefined) { + result.metadata = undefined; + } else { + const metadata = { ...(result.metadata ?? {}) }; + for (const [key, value] of Object.entries(patch.metadata)) { + if (value === undefined) delete metadata[key]; + else metadata[key] = value; + } + result.metadata = Object.keys(metadata).length > 0 ? metadata : undefined; + } + } + + return result; +} + +const SUBSCRIBER_EVENT_TYPE = "*"; + +type AgentHarnessHandler = (event: any, signal?: AbortSignal) => Promise<any> | any; + +function normalizeHarnessError(error: unknown, fallbackCode: AgentHarnessError["code"]): AgentHarnessError { + if (error instanceof AgentHarnessError) return error; + const cause = toError(error); + if (cause instanceof SessionError) return new AgentHarnessError("session", cause.message, cause); + if (cause instanceof CompactionError) return new AgentHarnessError("compaction", cause.message, cause); + if (cause instanceof BranchSummaryError) return new AgentHarnessError("branch_summary", cause.message, cause); + return new AgentHarnessError(fallbackCode, cause.message, cause); +} + +function normalizeHookError(error: unknown): AgentHarnessError { + return normalizeHarnessError(error, "hook"); +} + +interface AgentHarnessTurnState< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, + TTool extends AgentTool = AgentTool, +> { + messages: AgentMessage[]; + resources: AgentHarnessResources<TSkill, TPromptTemplate>; + streamOptions: AgentHarnessStreamOptions; + sessionId: string; + systemPrompt: string; + model: Model<any>; + thinkingLevel: ThinkingLevel; + tools: TTool[]; + activeTools: TTool[]; +} + +export class AgentHarness< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, + TTool extends AgentTool = AgentTool, +> { + readonly env: ExecutionEnv; + private session: Session; + private phase: AgentHarnessPhase = "idle"; + private runAbortController?: AbortController; + private runPromise?: Promise<void>; + private pendingSessionWrites: PendingSessionWrite[] = []; + private model: Model<any>; + private thinkingLevel: ThinkingLevel; + private systemPrompt: AgentHarnessOptions<TSkill, TPromptTemplate, TTool>["systemPrompt"]; + private streamOptions: AgentHarnessStreamOptions; + private getApiKeyAndHeaders?: AgentHarnessOptions["getApiKeyAndHeaders"]; + private resources: AgentHarnessResources<TSkill, TPromptTemplate>; + private tools = new Map<string, TTool>(); + private activeToolNames: string[]; + private steerQueue: UserMessage[] = []; + private steeringQueueMode: QueueMode; + private followUpQueue: UserMessage[] = []; + private followUpQueueMode: QueueMode; + private nextTurnQueue: AgentMessage[] = []; + private handlers = new Map<string, Set<AgentHarnessHandler>>(); + + constructor(options: AgentHarnessOptions<TSkill, TPromptTemplate, TTool>) { + this.env = options.env; + this.session = options.session; + this.resources = options.resources ?? {}; + this.streamOptions = cloneStreamOptions(options.streamOptions); + this.systemPrompt = options.systemPrompt; + this.getApiKeyAndHeaders = options.getApiKeyAndHeaders; + for (const tool of options.tools ?? []) { + this.tools.set(tool.name, tool); + } + this.model = options.model; + this.thinkingLevel = options.thinkingLevel ?? "off"; + this.activeToolNames = options.activeToolNames ?? (options.tools ?? []).map((tool) => tool.name); + this.steeringQueueMode = options.steeringMode ?? "one-at-a-time"; + this.followUpQueueMode = options.followUpMode ?? "one-at-a-time"; + } + + private getHandlers(type: string): Set<AgentHarnessHandler> | undefined { + return this.handlers.get(type); + } + + private async emitOwn(event: AgentHarnessOwnEvent<TSkill, TPromptTemplate>, signal?: AbortSignal): Promise<void> { + for (const listener of this.getHandlers(SUBSCRIBER_EVENT_TYPE) ?? []) { + try { + await listener(event, signal); + } catch (error) { + throw normalizeHookError(error); + } + } + } + + private async emitAny(event: AgentHarnessEvent<TSkill, TPromptTemplate>, signal?: AbortSignal): Promise<void> { + for (const listener of this.getHandlers(SUBSCRIBER_EVENT_TYPE) ?? []) { + try { + await listener(event, signal); + } catch (error) { + throw normalizeHookError(error); + } + } + } + + private async emitHook<TType extends keyof AgentHarnessEventResultMap>( + event: Extract<AgentHarnessOwnEvent, { type: TType }>, + ): Promise<AgentHarnessEventResultMap[TType] | undefined> { + const handlers = this.getHandlers(event.type as TType); + if (!handlers || handlers.size === 0) return undefined; + let lastResult: AgentHarnessEventResultMap[TType] | undefined; + for (const handler of handlers) { + try { + const result = await handler(event); + if (result !== undefined) { + lastResult = result; + } + } catch (error) { + throw normalizeHookError(error); + } + } + return lastResult; + } + + private async emitBeforeProviderRequest( + model: Model<any>, + sessionId: string, + streamOptions: AgentHarnessStreamOptions, + ): Promise<AgentHarnessStreamOptions> { + const handlers = this.getHandlers("before_provider_request"); + let current = cloneStreamOptions(streamOptions); + if (!handlers || handlers.size === 0) return current; + for (const handler of handlers) { + try { + const result = await handler({ + type: "before_provider_request", + model, + sessionId, + streamOptions: cloneStreamOptions(current), + }); + if (result?.streamOptions) { + current = applyStreamOptionsPatch(current, result.streamOptions); + } + } catch (error) { + throw normalizeHookError(error); + } + } + return current; + } + + private async emitBeforeProviderPayload(model: Model<any>, payload: unknown): Promise<unknown> { + const handlers = this.getHandlers("before_provider_payload"); + let current = payload; + if (!handlers || handlers.size === 0) return current; + for (const handler of handlers) { + try { + const result = await handler({ type: "before_provider_payload", model, payload: current }); + if (result !== undefined) { + current = result.payload; + } + } catch (error) { + throw normalizeHookError(error); + } + } + return current; + } + + private async emitQueueUpdate(): Promise<void> { + await this.emitOwn({ + type: "queue_update", + steer: [...this.steerQueue], + followUp: [...this.followUpQueue], + nextTurn: [...this.nextTurnQueue], + }); + } + + private startRunPromise(): () => void { + let finish = () => {}; + this.runPromise = new Promise<void>((resolve) => { + finish = resolve; + }); + return () => { + this.runPromise = undefined; + finish(); + }; + } + + private async createTurnState(): Promise<AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>> { + const context = await this.session.buildContext(); + const resources = this.getResources(); + const sessionMetadata = await this.session.getMetadata(); + const tools = [...this.tools.values()]; + const activeTools = this.activeToolNames + .map((name) => this.tools.get(name)) + .filter((tool): tool is TTool => tool !== undefined); + let systemPrompt = "You are a helpful assistant."; + if (typeof this.systemPrompt === "string") { + systemPrompt = this.systemPrompt; + } else if (this.systemPrompt) { + systemPrompt = await this.systemPrompt({ + env: this.env, + session: this.session, + model: this.model, + thinkingLevel: this.thinkingLevel, + activeTools, + resources, + }); + } + return { + messages: context.messages, + resources, + streamOptions: cloneStreamOptions(this.streamOptions), + sessionId: sessionMetadata.id, + systemPrompt, + model: this.model, + thinkingLevel: this.thinkingLevel, + tools, + activeTools, + }; + } + + private createContext( + turnState: AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>, + systemPrompt?: string, + ): AgentContext { + return { + systemPrompt: systemPrompt ?? turnState.systemPrompt, + messages: turnState.messages.slice(), + tools: turnState.activeTools.slice(), + }; + } + + private createStreamFn(getTurnState: () => AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>): StreamFn { + return async (model, context, streamOptions) => { + const turnState = getTurnState(); + const auth = await this.getApiKeyAndHeaders?.(model); + const snapshotOptions: AgentHarnessStreamOptions = { + ...turnState.streamOptions, + headers: mergeHeaders(turnState.streamOptions.headers, auth?.headers), + }; + const requestOptions = await this.emitBeforeProviderRequest(model, turnState.sessionId, snapshotOptions); + return streamSimple(model, context, { + cacheRetention: requestOptions.cacheRetention, + headers: requestOptions.headers, + maxRetries: requestOptions.maxRetries, + maxRetryDelayMs: requestOptions.maxRetryDelayMs, + metadata: requestOptions.metadata, + onPayload: async (payload) => await this.emitBeforeProviderPayload(model, payload), + onResponse: async (response) => { + const headers = { ...(response.headers as Record<string, string>) }; + await this.emitOwn( + { type: "after_provider_response", status: response.status, headers }, + streamOptions?.signal, + ); + }, + reasoning: streamOptions?.reasoning, + signal: streamOptions?.signal, + sessionId: turnState.sessionId, + timeoutMs: requestOptions.timeoutMs, + transport: requestOptions.transport, + apiKey: auth?.apiKey, + }); + }; + } + + private async drainQueuedMessages(queue: AgentMessage[], mode: QueueMode): Promise<AgentMessage[]> { + const messages = mode === "all" ? queue.splice(0) : queue.splice(0, 1); + if (messages.length === 0) return messages; + try { + await this.emitQueueUpdate(); + return messages; + } catch (error) { + queue.unshift(...messages); + throw normalizeHookError(error); + } + } + + private createLoopConfig( + getTurnState: () => AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>, + setTurnState: (turnState: AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>) => void, + ): AgentLoopConfig { + const turnState = getTurnState(); + return { + model: turnState.model, + reasoning: turnState.thinkingLevel === "off" ? undefined : turnState.thinkingLevel, + convertToLlm, + transformContext: async (messages) => { + const result = await this.emitHook({ type: "context", messages: [...messages] }); + return result?.messages ?? messages; + }, + beforeToolCall: async ({ toolCall, args }) => { + const result = await this.emitHook({ + type: "tool_call", + toolCallId: toolCall.id, + toolName: toolCall.name, + input: args as Record<string, unknown>, + }); + return result ? { block: result.block, reason: result.reason } : undefined; + }, + afterToolCall: async ({ toolCall, args, result, isError }) => { + const patch = await this.emitHook({ + type: "tool_result", + toolCallId: toolCall.id, + toolName: toolCall.name, + input: args as Record<string, unknown>, + content: result.content, + details: result.details, + isError, + }); + return patch + ? { content: patch.content, details: patch.details, isError: patch.isError, terminate: patch.terminate } + : undefined; + }, + prepareNextTurn: async () => { + await this.flushPendingSessionWrites(); + const nextTurnState = await this.createTurnState(); + setTurnState(nextTurnState); + return { + context: this.createContext(nextTurnState), + model: nextTurnState.model, + thinkingLevel: nextTurnState.thinkingLevel, + }; + }, + getSteeringMessages: async () => this.drainQueuedMessages(this.steerQueue, this.steeringQueueMode), + getFollowUpMessages: async () => this.drainQueuedMessages(this.followUpQueue, this.followUpQueueMode), + }; + } + + private validateToolNames(toolNames: string[], tools: Map<string, TTool> = this.tools): void { + const missing = toolNames.filter((name) => !tools.has(name)); + if (missing.length > 0) throw new AgentHarnessError("invalid_argument", `Unknown tool(s): ${missing.join(", ")}`); + } + + private async flushPendingSessionWrites(): Promise<void> { + while (this.pendingSessionWrites.length > 0) { + const write = this.pendingSessionWrites[0]!; + if (write.type === "message") { + await this.session.appendMessage(write.message); + } else if (write.type === "model_change") { + await this.session.appendModelChange(write.provider, write.modelId); + } else if (write.type === "thinking_level_change") { + await this.session.appendThinkingLevelChange(write.thinkingLevel); + } else if (write.type === "custom") { + await this.session.appendCustomEntry(write.customType, write.data); + } else if (write.type === "custom_message") { + await this.session.appendCustomMessageEntry(write.customType, write.content, write.display, write.details); + } else if (write.type === "label") { + await this.session.appendLabel(write.targetId, write.label); + } else if (write.type === "session_info") { + await this.session.appendSessionName(write.name ?? ""); + } else if (write.type === "leaf") { + await this.session.getStorage().setLeafId(write.targetId); + } + this.pendingSessionWrites.shift(); + } + } + + private async handleAgentEvent(event: AgentEvent, signal?: AbortSignal): Promise<void> { + if (event.type === "message_end") { + await this.session.appendMessage(event.message); + await this.emitAny(event, signal); + return; + } + if (event.type === "turn_end") { + let eventError: unknown; + try { + await this.emitAny(event, signal); + } catch (error) { + eventError = error; + } + const hadPendingMutations = this.pendingSessionWrites.length > 0; + await this.flushPendingSessionWrites(); + if (eventError) throw eventError; + await this.emitOwn({ type: "save_point", hadPendingMutations }); + return; + } + if (event.type === "agent_end") { + await this.flushPendingSessionWrites(); + this.phase = "idle"; + await this.emitAny(event, signal); + await this.emitOwn({ type: "settled", nextTurnCount: this.nextTurnQueue.length }, signal); + return; + } + await this.emitAny(event, signal); + } + + private async emitRunFailure( + model: Model<any>, + error: unknown, + aborted: boolean, + signal: AbortSignal, + ): Promise<AgentMessage[]> { + const failureMessage = createFailureMessage(model, error, aborted); + await this.handleAgentEvent({ type: "message_start", message: failureMessage }, signal); + await this.handleAgentEvent({ type: "message_end", message: failureMessage }, signal); + await this.handleAgentEvent({ type: "turn_end", message: failureMessage, toolResults: [] }, signal); + await this.handleAgentEvent({ type: "agent_end", messages: [failureMessage] }, signal); + return [failureMessage]; + } + + private async executeTurn( + turnState: AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>, + text: string, + options?: { images?: ImageContent[] }, + ): Promise<AssistantMessage> { + let activeTurnState = turnState; + let messages: AgentMessage[] = [createUserMessage(text, options?.images)]; + if (this.nextTurnQueue.length > 0) { + const queuedMessages = this.nextTurnQueue.splice(0); + try { + await this.emitQueueUpdate(); + } catch (error) { + this.nextTurnQueue.unshift(...queuedMessages); + throw normalizeHookError(error); + } + messages = [...queuedMessages, messages[0]!]; + } + const beforeResult = await this.emitHook({ + type: "before_agent_start", + prompt: text, + images: options?.images, + systemPrompt: turnState.systemPrompt, + resources: turnState.resources, + }); + if (beforeResult?.messages) messages = [...messages, ...beforeResult.messages]; + + const abortController = new AbortController(); + const getTurnState = () => activeTurnState; + const setTurnState = (nextTurnState: AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>) => { + activeTurnState = nextTurnState; + }; + this.runAbortController = abortController; + const runResultPromise = (async () => { + try { + return await runAgentLoop( + messages, + this.createContext(turnState, beforeResult?.systemPrompt), + this.createLoopConfig(getTurnState, setTurnState), + (event) => this.handleAgentEvent(event, abortController.signal), + abortController.signal, + this.createStreamFn(getTurnState), + ); + } catch (error) { + try { + return await this.emitRunFailure( + activeTurnState.model, + error, + abortController.signal.aborted, + abortController.signal, + ); + } catch (failureError) { + const cause = new AggregateError( + [toError(error), toError(failureError)], + "Agent run failed and failure reporting failed", + ); + throw new AgentHarnessError("unknown", cause.message, cause); + } + } + })(); + try { + const newMessages = await runResultPromise; + for (let i = newMessages.length - 1; i >= 0; i--) { + const message = newMessages[i]!; + if (message.role === "assistant") { + return message; + } + } + throw new AgentHarnessError("invalid_state", "AgentHarness prompt completed without an assistant message"); + } finally { + try { + await this.flushPendingSessionWrites(); + } finally { + this.runAbortController = undefined; + } + } + } + + async prompt(text: string, options?: { images?: ImageContent[] }): Promise<AssistantMessage> { + if (this.phase !== "idle") throw new AgentHarnessError("busy", "AgentHarness is busy"); + this.phase = "turn"; + const finishRunPromise = this.startRunPromise(); + try { + const turnState = await this.createTurnState(); + return await this.executeTurn(turnState, text, options); + } catch (error) { + this.phase = "idle"; + throw normalizeHarnessError(error, "unknown"); + } finally { + finishRunPromise(); + } + } + + async skill(name: string, additionalInstructions?: string): Promise<AssistantMessage> { + if (this.phase !== "idle") throw new AgentHarnessError("busy", "AgentHarness is busy"); + this.phase = "turn"; + const finishRunPromise = this.startRunPromise(); + try { + const turnState = await this.createTurnState(); + const skill = (turnState.resources.skills ?? []).find((candidate) => candidate.name === name); + if (!skill) throw new AgentHarnessError("invalid_argument", `Unknown skill: ${name}`); + return await this.executeTurn(turnState, formatSkillInvocation(skill, additionalInstructions)); + } catch (error) { + this.phase = "idle"; + throw normalizeHarnessError(error, "unknown"); + } finally { + finishRunPromise(); + } + } + + async promptFromTemplate(name: string, args: string[] = []): Promise<AssistantMessage> { + if (this.phase !== "idle") throw new AgentHarnessError("busy", "AgentHarness is busy"); + this.phase = "turn"; + const finishRunPromise = this.startRunPromise(); + try { + const turnState = await this.createTurnState(); + const template = (turnState.resources.promptTemplates ?? []).find((candidate) => candidate.name === name); + if (!template) throw new AgentHarnessError("invalid_argument", `Unknown prompt template: ${name}`); + return await this.executeTurn(turnState, formatPromptTemplateInvocation(template, args)); + } catch (error) { + this.phase = "idle"; + throw normalizeHarnessError(error, "unknown"); + } finally { + finishRunPromise(); + } + } + + async steer(text: string, options?: { images?: ImageContent[] }): Promise<void> { + if (this.phase === "idle") throw new AgentHarnessError("invalid_state", "Cannot steer while idle"); + this.steerQueue.push(createUserMessage(text, options?.images)); + await this.emitQueueUpdate(); + } + + async followUp(text: string, options?: { images?: ImageContent[] }): Promise<void> { + if (this.phase === "idle") throw new AgentHarnessError("invalid_state", "Cannot follow up while idle"); + this.followUpQueue.push(createUserMessage(text, options?.images)); + await this.emitQueueUpdate(); + } + + async nextTurn(text: string, options?: { images?: ImageContent[] }): Promise<void> { + this.nextTurnQueue.push(createUserMessage(text, options?.images)); + await this.emitQueueUpdate(); + } + + async appendMessage(message: AgentMessage): Promise<void> { + try { + if (this.phase === "idle") { + await this.session.appendMessage(message); + } else { + this.pendingSessionWrites.push({ type: "message", message }); + } + } catch (error) { + throw normalizeHarnessError(error, "session"); + } + } + + async compact( + customInstructions?: string, + ): Promise<{ summary: string; firstKeptEntryId: string; tokensBefore: number; details?: unknown }> { + if (this.phase !== "idle") throw new AgentHarnessError("busy", "compact() requires idle harness"); + this.phase = "compaction"; + try { + const model = this.model; + if (!model) throw new AgentHarnessError("invalid_state", "No model set for compaction"); + const auth = await this.getApiKeyAndHeaders?.(model); + if (!auth) throw new AgentHarnessError("auth", "No auth available for compaction"); + const branchEntries = await this.session.getBranch(); + const preparationResult = prepareCompaction(branchEntries, DEFAULT_COMPACTION_SETTINGS); + if (!preparationResult.ok) throw preparationResult.error; + const preparation = preparationResult.value; + if (!preparation) throw new AgentHarnessError("compaction", "Nothing to compact"); + const hookResult = await this.emitHook({ + type: "session_before_compact", + preparation, + branchEntries, + customInstructions, + signal: new AbortController().signal, + }); + if (hookResult?.cancel) throw new AgentHarnessError("compaction", "Compaction cancelled"); + const provided = hookResult?.compaction; + const compactResult = provided + ? { ok: true as const, value: provided } + : await compact( + preparation, + model, + auth.apiKey, + auth.headers, + customInstructions, + undefined, + this.thinkingLevel, + ); + if (!compactResult.ok) throw compactResult.error; + const result = compactResult.value; + const entryId = await this.session.appendCompaction( + result.summary, + result.firstKeptEntryId, + result.tokensBefore, + result.details, + provided !== undefined, + ); + const entry = await this.session.getEntry(entryId); + if (entry?.type === "compaction") { + await this.emitOwn({ type: "session_compact", compactionEntry: entry, fromHook: provided !== undefined }); + } + return result; + } catch (error) { + throw normalizeHarnessError(error, "compaction"); + } finally { + this.phase = "idle"; + } + } + + async navigateTree( + targetId: string, + options?: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string }, + ): Promise<NavigateTreeResult> { + if (this.phase !== "idle") throw new AgentHarnessError("busy", "navigateTree() requires idle harness"); + this.phase = "branch_summary"; + try { + const oldLeafId = await this.session.getLeafId(); + if (oldLeafId === targetId) return { cancelled: false }; + const targetEntry = await this.session.getEntry(targetId); + if (!targetEntry) throw new AgentHarnessError("invalid_argument", `Entry ${targetId} not found`); + const { entries, commonAncestorId } = await collectEntriesForBranchSummary(this.session, oldLeafId, targetId); + const preparation = { + targetId, + oldLeafId, + commonAncestorId, + entriesToSummarize: entries, + userWantsSummary: options?.summarize ?? false, + customInstructions: options?.customInstructions, + replaceInstructions: options?.replaceInstructions, + label: options?.label, + }; + const signal = new AbortController().signal; + const hookResult = await this.emitHook({ type: "session_before_tree", preparation, signal }); + if (hookResult?.cancel) return { cancelled: true }; + let summaryEntry: NavigateTreeResult["summaryEntry"]; + let summaryText: string | undefined = hookResult?.summary?.summary; + let summaryDetails: unknown = hookResult?.summary?.details; + if (!summaryText && options?.summarize && entries.length > 0) { + const model = this.model; + if (!model) throw new AgentHarnessError("invalid_state", "No model set for branch summary"); + const auth = await this.getApiKeyAndHeaders?.(model); + if (!auth) throw new AgentHarnessError("auth", "No auth available for branch summary"); + const branchSummary = await generateBranchSummary(entries, { + model, + apiKey: auth.apiKey, + headers: auth.headers, + signal: new AbortController().signal, + customInstructions: hookResult?.customInstructions ?? options?.customInstructions, + replaceInstructions: hookResult?.replaceInstructions ?? options?.replaceInstructions, + }); + if (!branchSummary.ok) { + if (branchSummary.error.code === "aborted") return { cancelled: true }; + throw new AgentHarnessError("branch_summary", branchSummary.error.message, branchSummary.error); + } + summaryText = branchSummary.value.summary; + summaryDetails = { + readFiles: branchSummary.value.readFiles, + modifiedFiles: branchSummary.value.modifiedFiles, + }; + } + let editorText: string | undefined; + let newLeafId: string | null; + if (targetEntry.type === "message" && targetEntry.message.role === "user") { + newLeafId = targetEntry.parentId; + const content = targetEntry.message.content; + editorText = + typeof content === "string" + ? content + : content + .filter((c): c is { readonly type: "text"; readonly text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + } else if (targetEntry.type === "custom_message") { + newLeafId = targetEntry.parentId; + editorText = + typeof targetEntry.content === "string" + ? targetEntry.content + : targetEntry.content + .filter((c): c is { readonly type: "text"; readonly text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + } else { + newLeafId = targetId; + } + const summaryId = await this.session.moveTo( + newLeafId, + summaryText + ? { summary: summaryText, details: summaryDetails, fromHook: hookResult?.summary !== undefined } + : undefined, + ); + if (summaryId) { + const entry = await this.session.getEntry(summaryId); + if (entry?.type === "branch_summary") summaryEntry = entry; + } + await this.emitOwn({ + type: "session_tree", + newLeafId: await this.session.getLeafId(), + oldLeafId, + summaryEntry, + fromHook: hookResult?.summary !== undefined, + }); + return { cancelled: false, editorText, summaryEntry }; + } catch (error) { + throw normalizeHarnessError(error, "branch_summary"); + } finally { + this.phase = "idle"; + } + } + + getModel(): Model<any> { + return this.model; + } + + getThinkingLevel(): ThinkingLevel { + return this.thinkingLevel; + } + + async setModel(model: Model<any>): Promise<void> { + try { + const previousModel = this.model; + if (this.phase === "idle") { + await this.session.appendModelChange(model.provider, model.id); + } else { + this.pendingSessionWrites.push({ type: "model_change", provider: model.provider, modelId: model.id }); + } + this.model = model; + await this.emitOwn({ type: "model_select", model, previousModel, source: "set" }); + } catch (error) { + throw normalizeHarnessError(error, "session"); + } + } + + async setThinkingLevel(level: ThinkingLevel): Promise<void> { + try { + const previousLevel = this.thinkingLevel; + if (this.phase === "idle") { + await this.session.appendThinkingLevelChange(level); + } else { + this.pendingSessionWrites.push({ type: "thinking_level_change", thinkingLevel: level }); + } + this.thinkingLevel = level; + await this.emitOwn({ type: "thinking_level_select", level, previousLevel }); + } catch (error) { + throw normalizeHarnessError(error, "session"); + } + } + + async setActiveTools(toolNames: string[]): Promise<void> { + try { + this.validateToolNames(toolNames); + this.activeToolNames = [...toolNames]; + } catch (error) { + throw normalizeHarnessError(error, "invalid_argument"); + } + } + + getSteeringMode(): QueueMode { + return this.steeringQueueMode; + } + + async setSteeringMode(mode: QueueMode): Promise<void> { + this.steeringQueueMode = mode; + } + + getFollowUpMode(): QueueMode { + return this.followUpQueueMode; + } + + async setFollowUpMode(mode: QueueMode): Promise<void> { + this.followUpQueueMode = mode; + } + + getResources(): AgentHarnessResources<TSkill, TPromptTemplate> { + return { + skills: this.resources.skills?.slice(), + promptTemplates: this.resources.promptTemplates?.slice(), + }; + } + + async setResources(resources: AgentHarnessResources<TSkill, TPromptTemplate>): Promise<void> { + const previousResources = this.getResources(); + this.resources = { + skills: resources.skills?.slice(), + promptTemplates: resources.promptTemplates?.slice(), + }; + await this.emitOwn({ type: "resources_update", resources: this.getResources(), previousResources }); + } + + getStreamOptions(): AgentHarnessStreamOptions { + return cloneStreamOptions(this.streamOptions); + } + + async setStreamOptions(streamOptions: AgentHarnessStreamOptions): Promise<void> { + this.streamOptions = cloneStreamOptions(streamOptions); + } + + async setTools(tools: TTool[], activeToolNames?: string[]): Promise<void> { + try { + const nextTools = new Map(tools.map((tool) => [tool.name, tool])); + const nextActiveToolNames = activeToolNames ? [...activeToolNames] : this.activeToolNames; + this.validateToolNames(nextActiveToolNames, nextTools); + this.tools = nextTools; + this.activeToolNames = [...nextActiveToolNames]; + } catch (error) { + throw normalizeHarnessError(error, "invalid_argument"); + } + } + + async abort(): Promise<AbortResult> { + const clearedSteer = [...this.steerQueue]; + const clearedFollowUp = [...this.followUpQueue]; + this.steerQueue = []; + this.followUpQueue = []; + this.runAbortController?.abort(); + const errors: Error[] = []; + try { + await this.emitQueueUpdate(); + } catch (error) { + errors.push(toError(error)); + } + try { + await this.waitForIdle(); + } catch (error) { + errors.push(toError(error)); + } + try { + await this.emitOwn({ type: "abort", clearedSteer, clearedFollowUp }); + } catch (error) { + errors.push(toError(error)); + } + if (errors.length > 0) { + const cause = errors.length === 1 ? errors[0]! : new AggregateError(errors, "Abort completed with errors"); + throw normalizeHarnessError(cause, "hook"); + } + return { clearedSteer, clearedFollowUp }; + } + + async waitForIdle(): Promise<void> { + await this.runPromise; + } + + subscribe( + listener: (event: AgentHarnessEvent<TSkill, TPromptTemplate>, signal?: AbortSignal) => Promise<void> | void, + ): () => void { + let handlers = this.handlers.get(SUBSCRIBER_EVENT_TYPE); + if (!handlers) { + handlers = new Set(); + this.handlers.set(SUBSCRIBER_EVENT_TYPE, handlers); + } + handlers.add(listener as AgentHarnessHandler); + return () => handlers!.delete(listener as AgentHarnessHandler); + } + + on<TType extends keyof AgentHarnessEventResultMap>( + type: TType, + handler: ( + event: Extract<AgentHarnessOwnEvent, { type: TType }>, + ) => Promise<AgentHarnessEventResultMap[TType]> | AgentHarnessEventResultMap[TType], + ): () => void { + let handlers = this.handlers.get(type); + if (!handlers) { + handlers = new Set(); + this.handlers.set(type, handlers); + } + handlers.add(handler as AgentHarnessHandler); + return () => handlers!.delete(handler as AgentHarnessHandler); + } +} diff --git a/emain/agent/harness/compaction/branch-summarization.ts b/emain/agent/harness/compaction/branch-summarization.ts new file mode 100644 index 000000000..9c1cc39b1 --- /dev/null +++ b/emain/agent/harness/compaction/branch-summarization.ts @@ -0,0 +1,262 @@ +import type { Model } from "../../../ai"; +import { completeSimple } from "../../../ai"; +import type { AgentMessage } from "../../types"; +import { + convertToLlm, + createBranchSummaryMessage, + createCompactionSummaryMessage, + createCustomMessage, +} from "../messages"; +import type { BranchSummaryResult, Session, SessionTreeEntry } from "../types"; +import { BranchSummaryError, err, ok, type Result, SessionError } from "../types"; +import { estimateTokens, SUMMARIZATION_SYSTEM_PROMPT } from "./compaction"; +import { + computeFileLists, + createFileOps, + extractFileOpsFromMessage, + type FileOperations, + formatFileOperations, + serializeConversation, +} from "./utils"; + +/** File-operation details stored on generated branch summary entries. */ +export interface BranchSummaryDetails { + /** Files read while exploring the summarized branch. */ + readFiles: string[]; + /** Files modified while exploring the summarized branch. */ + modifiedFiles: string[]; +} + +export type { FileOperations } from "./utils"; + +/** Prepared branch content for summarization. */ +export interface BranchPreparation { + /** Messages selected for the branch summary. */ + messages: AgentMessage[]; + /** File operations extracted from the branch. */ + fileOps: FileOperations; + /** Estimated token count for selected messages. */ + totalTokens: number; +} + +/** Entries selected for branch summarization. */ +export interface CollectEntriesResult { + /** Entries to summarize in chronological order. */ + entries: SessionTreeEntry[]; + /** Deepest common ancestor between the previous leaf and target entry. */ + commonAncestorId: string | null; +} + +/** Options for generating a branch summary. */ +export interface GenerateBranchSummaryOptions { + /** Model used for summarization. */ + model: Model<any>; + /** API key forwarded to the provider. */ + apiKey: string; + /** Optional request headers forwarded to the provider. */ + headers?: Record<string, string>; + /** Abort signal for the summarization request. */ + signal: AbortSignal; + /** Optional instructions appended to or replacing the default prompt. */ + customInstructions?: string; + /** Replace the default prompt with custom instructions instead of appending them. */ + replaceInstructions?: boolean; + /** Tokens reserved for prompt and model output. Defaults to 16384. */ + reserveTokens?: number; +} + +/** Collect entries that should be summarized before navigating to a different session tree entry. */ +export async function collectEntriesForBranchSummary( + session: Session, + oldLeafId: string | null, + targetId: string, +): Promise<CollectEntriesResult> { + if (!oldLeafId) { + return { entries: [], commonAncestorId: null }; + } + const oldPath = new Set((await session.getBranch(oldLeafId)).map((e) => e.id)); + const targetPath = await session.getBranch(targetId); + let commonAncestorId: string | null = null; + for (let i = targetPath.length - 1; i >= 0; i--) { + if (oldPath.has(targetPath[i].id)) { + commonAncestorId = targetPath[i].id; + break; + } + } + const entries: SessionTreeEntry[] = []; + let current: string | null = oldLeafId; + + while (current && current !== commonAncestorId) { + const entry = await session.getEntry(current); + if (!entry) throw new SessionError("invalid_session", `Entry ${current} not found`); + entries.push(entry as SessionTreeEntry); + current = entry.parentId; + } + entries.reverse(); + + return { entries, commonAncestorId }; +} +function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined { + switch (entry.type) { + case "message": + if (entry.message.role === "toolResult") return undefined; + return entry.message; + + case "custom_message": + return createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp); + + case "branch_summary": + return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp); + + case "compaction": + return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp); + case "thinking_level_change": + case "model_change": + case "custom": + case "label": + case "session_info": + case "leaf": + return undefined; + } +} + +/** Prepare branch entries for summarization within an optional token budget. */ +export function prepareBranchEntries(entries: SessionTreeEntry[], tokenBudget: number = 0): BranchPreparation { + const messages: AgentMessage[] = []; + const fileOps = createFileOps(); + let totalTokens = 0; + for (const entry of entries) { + if (entry.type === "branch_summary" && !entry.fromHook && entry.details) { + const details = entry.details as BranchSummaryDetails; + if (Array.isArray(details.readFiles)) { + for (const f of details.readFiles) fileOps.read.add(f); + } + if (Array.isArray(details.modifiedFiles)) { + for (const f of details.modifiedFiles) { + fileOps.edited.add(f); + } + } + } + } + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + const message = getMessageFromEntry(entry); + if (!message) continue; + extractFileOpsFromMessage(message, fileOps); + + const tokens = estimateTokens(message); + if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) { + if (entry.type === "compaction" || entry.type === "branch_summary") { + if (totalTokens < tokenBudget * 0.9) { + messages.unshift(message); + totalTokens += tokens; + } + } + break; + } + + messages.unshift(message); + totalTokens += tokens; + } + + return { messages, fileOps, totalTokens }; +} + +const BRANCH_SUMMARY_PREAMBLE = `The user explored a different conversation branch before returning here. +Summary of that exploration: + +`; + +const BRANCH_SUMMARY_PROMPT = `Create a structured summary of this conversation branch for context when returning later. + +Use this EXACT format: + +## Goal +[What was the user trying to accomplish in this branch?] + +## Constraints & Preferences +- [Any constraints, preferences, or requirements mentioned] +- [Or "(none)" if none were mentioned] + +## Progress +### Done +- [x] [Completed tasks/changes] + +### In Progress +- [ ] [Work that was started but not finished] + +### Blocked +- [Issues preventing progress, if any] + +## Key Decisions +- **[Decision]**: [Brief rationale] + +## Next Steps +1. [What should happen next to continue this work] + +Keep each section concise. Preserve exact file paths, function names, and error messages.`; + +/** Generate a summary for abandoned branch entries. */ +export async function generateBranchSummary( + entries: SessionTreeEntry[], + options: GenerateBranchSummaryOptions, +): Promise<Result<BranchSummaryResult, BranchSummaryError>> { + const { model, apiKey, headers, signal, customInstructions, replaceInstructions, reserveTokens = 16384 } = options; + const contextWindow = model.contextWindow || 128000; + const tokenBudget = contextWindow - reserveTokens; + + const { messages, fileOps } = prepareBranchEntries(entries, tokenBudget); + + if (messages.length === 0) { + return ok({ summary: "No content to summarize", readFiles: [], modifiedFiles: [] }); + } + const llmMessages = convertToLlm(messages); + const conversationText = serializeConversation(llmMessages); + let instructions: string; + if (replaceInstructions && customInstructions) { + instructions = customInstructions; + } else if (customInstructions) { + instructions = `${BRANCH_SUMMARY_PROMPT}\n\nAdditional focus: ${customInstructions}`; + } else { + instructions = BRANCH_SUMMARY_PROMPT; + } + const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${instructions}`; + + const summarizationMessages = [ + { + role: "user" as const, + content: [{ type: "text" as const, text: promptText }], + timestamp: Date.now(), + }, + ]; + const response = await completeSimple( + model, + { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, + { apiKey, headers, signal, maxTokens: 2048 }, + ); + if (response.stopReason === "aborted") { + return err(new BranchSummaryError("aborted", response.errorMessage || "Branch summary aborted")); + } + if (response.stopReason === "error") { + return err( + new BranchSummaryError( + "summarization_failed", + `Branch summary failed: ${response.errorMessage || "Unknown error"}`, + ), + ); + } + + let summary = response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"); + summary = BRANCH_SUMMARY_PREAMBLE + summary; + const { readFiles, modifiedFiles } = computeFileLists(fileOps); + summary += formatFileOperations(readFiles, modifiedFiles); + + return ok({ + summary: summary || "No summary generated", + readFiles, + modifiedFiles, + }); +} diff --git a/emain/agent/harness/compaction/compaction.ts b/emain/agent/harness/compaction/compaction.ts new file mode 100644 index 000000000..2120eac1b --- /dev/null +++ b/emain/agent/harness/compaction/compaction.ts @@ -0,0 +1,755 @@ +import type { AssistantMessage, ImageContent, Model, TextContent, Usage } from "../../../ai"; +import { completeSimple } from "../../../ai"; +import type { AgentMessage, ThinkingLevel } from "../../types"; +import { + convertToLlm, + createBranchSummaryMessage, + createCompactionSummaryMessage, + createCustomMessage, +} from "../messages"; +import { buildSessionContext } from "../session/session"; +import { type CompactionEntry, CompactionError, err, ok, type Result, type SessionTreeEntry } from "../types"; +import { + computeFileLists, + createFileOps, + extractFileOpsFromMessage, + type FileOperations, + formatFileOperations, + serializeConversation, +} from "./utils"; + +/** File-operation details stored on generated compaction entries. */ +export interface CompactionDetails { + /** Files read in the compacted history. */ + readFiles: string[]; + /** Files modified in the compacted history. */ + modifiedFiles: string[]; +} +function safeJsonStringify(value: unknown): string { + try { + return JSON.stringify(value) ?? "undefined"; + } catch { + return "[unserializable]"; + } +} + +function extractFileOperations( + messages: AgentMessage[], + entries: SessionTreeEntry[], + prevCompactionIndex: number, +): FileOperations { + const fileOps = createFileOps(); + if (prevCompactionIndex >= 0) { + const prevCompaction = entries[prevCompactionIndex] as CompactionEntry; + if (!prevCompaction.fromHook && prevCompaction.details) { + const details = prevCompaction.details as CompactionDetails; + if (Array.isArray(details.readFiles)) { + for (const f of details.readFiles) fileOps.read.add(f); + } + if (Array.isArray(details.modifiedFiles)) { + for (const f of details.modifiedFiles) fileOps.edited.add(f); + } + } + } + for (const msg of messages) { + extractFileOpsFromMessage(msg, fileOps); + } + + return fileOps; +} +function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined { + if (entry.type === "message") { + return entry.message as AgentMessage; + } + if (entry.type === "custom_message") { + return createCustomMessage( + entry.customType, + entry.content as string | (TextContent | ImageContent)[], + entry.display, + entry.details, + entry.timestamp, + ); + } + if (entry.type === "branch_summary") { + return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp); + } + if (entry.type === "compaction") { + return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp); + } + return undefined; +} + +function getMessageFromEntryForCompaction(entry: SessionTreeEntry): AgentMessage | undefined { + if (entry.type === "compaction") { + return undefined; + } + return getMessageFromEntry(entry); +} + +/** Generated compaction data ready to be persisted as a compaction entry. */ +export interface CompactionResult<T = unknown> { + /** Summary text that replaces compacted history in future context. */ + summary: string; + /** Entry id where retained history starts. */ + firstKeptEntryId: string; + /** Estimated context tokens before compaction. */ + tokensBefore: number; + /** Optional implementation-specific details stored with the compaction entry. */ + details?: T; +} + +/** Compaction thresholds and retention settings. */ +export interface CompactionSettings { + /** Enable automatic compaction decisions. */ + enabled: boolean; + /** Tokens reserved for summary prompt and output. */ + reserveTokens: number; + /** Approximate recent-context tokens to keep after compaction. */ + keepRecentTokens: number; +} + +/** Default compaction settings used by the harness. */ +export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = { + enabled: true, + reserveTokens: 16384, + keepRecentTokens: 20000, +}; + +/** Calculate total context tokens from provider usage. */ +export function calculateContextTokens(usage: Usage): number { + return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite; +} +function getAssistantUsage(msg: AgentMessage): Usage | undefined { + if (msg.role === "assistant" && "usage" in msg) { + const assistantMsg = msg as AssistantMessage; + if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) { + return assistantMsg.usage; + } + } + return undefined; +} + +/** Return usage from the last successful assistant message in session entries. */ +export function getLastAssistantUsage(entries: SessionTreeEntry[]): Usage | undefined { + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry.type === "message") { + const usage = getAssistantUsage(entry.message as AgentMessage); + if (usage) return usage; + } + } + return undefined; +} + +/** Estimated context-token usage for a message list. */ +export interface ContextUsageEstimate { + /** Estimated total context tokens. */ + tokens: number; + /** Tokens reported by the most recent assistant usage block. */ + usageTokens: number; + /** Estimated tokens after the most recent assistant usage block. */ + trailingTokens: number; + /** Index of the message that provided usage, or null when none exists. */ + lastUsageIndex: number | null; +} + +function getLastAssistantUsageInfo(messages: AgentMessage[]): { usage: Usage; index: number } | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const usage = getAssistantUsage(messages[i]); + if (usage) return { usage, index: i }; + } + return undefined; +} + +/** Estimate context tokens for messages using provider usage when available. */ +export function estimateContextTokens(messages: AgentMessage[]): ContextUsageEstimate { + const usageInfo = getLastAssistantUsageInfo(messages); + + if (!usageInfo) { + let estimated = 0; + for (const message of messages) { + estimated += estimateTokens(message); + } + return { + tokens: estimated, + usageTokens: 0, + trailingTokens: estimated, + lastUsageIndex: null, + }; + } + + const usageTokens = calculateContextTokens(usageInfo.usage); + let trailingTokens = 0; + for (let i = usageInfo.index + 1; i < messages.length; i++) { + trailingTokens += estimateTokens(messages[i]); + } + + return { + tokens: usageTokens + trailingTokens, + usageTokens, + trailingTokens, + lastUsageIndex: usageInfo.index, + }; +} + +/** Return whether context usage exceeds the configured compaction threshold. */ +export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean { + if (!settings.enabled) return false; + return contextTokens > contextWindow - settings.reserveTokens; +} + +/** Estimate token count for one message using a conservative character heuristic. */ +export function estimateTokens(message: AgentMessage): number { + let chars = 0; + + switch (message.role) { + case "user": { + const content = (message as { content: string | Array<{ type: string; text?: string }> }).content; + if (typeof content === "string") { + chars = content.length; + } else if (Array.isArray(content)) { + for (const block of content) { + if (block.type === "text" && block.text) { + chars += block.text.length; + } + } + } + return Math.ceil(chars / 4); + } + case "assistant": { + const assistant = message as AssistantMessage; + for (const block of assistant.content) { + if (block.type === "text") { + chars += block.text.length; + } else if (block.type === "thinking") { + chars += block.thinking.length; + } else if (block.type === "toolCall") { + chars += block.name.length + safeJsonStringify(block.arguments).length; + } + } + return Math.ceil(chars / 4); + } + case "custom": + case "toolResult": { + if (typeof message.content === "string") { + chars = message.content.length; + } else { + for (const block of message.content) { + if (block.type === "text" && block.text) { + chars += block.text.length; + } + if (block.type === "image") { + chars += 4800; + } + } + } + return Math.ceil(chars / 4); + } + case "bashExecution": { + chars = message.command.length + message.output.length; + return Math.ceil(chars / 4); + } + case "branchSummary": + case "compactionSummary": { + chars = message.summary.length; + return Math.ceil(chars / 4); + } + } + + return 0; +} +function findValidCutPoints(entries: SessionTreeEntry[], startIndex: number, endIndex: number): number[] { + const cutPoints: number[] = []; + for (let i = startIndex; i < endIndex; i++) { + const entry = entries[i]; + switch (entry.type) { + case "message": { + const role = entry.message.role; + switch (role) { + case "bashExecution": + case "custom": + case "branchSummary": + case "compactionSummary": + case "user": + case "assistant": + cutPoints.push(i); + break; + case "toolResult": + break; + } + break; + } + case "thinking_level_change": + case "model_change": + case "compaction": + case "branch_summary": + case "custom": + case "custom_message": + case "label": + case "session_info": + case "leaf": + break; + } + if (entry.type === "branch_summary" || entry.type === "custom_message") { + cutPoints.push(i); + } + } + return cutPoints; +} + +/** Find the user-visible message that starts the turn containing an entry. */ +export function findTurnStartIndex(entries: SessionTreeEntry[], entryIndex: number, startIndex: number): number { + for (let i = entryIndex; i >= startIndex; i--) { + const entry = entries[i]; + if (entry.type === "branch_summary" || entry.type === "custom_message") { + return i; + } + if (entry.type === "message") { + const role = entry.message.role; + if (role === "user" || role === "bashExecution") { + return i; + } + } + } + return -1; +} + +/** Cut point selected for compaction. */ +export interface CutPointResult { + /** Index of the first entry retained after compaction. */ + firstKeptEntryIndex: number; + /** Index of the turn-start entry when the cut splits a turn, otherwise -1. */ + turnStartIndex: number; + /** Whether the selected cut point splits an in-progress turn. */ + isSplitTurn: boolean; +} + +/** Find the compaction cut point that keeps approximately the requested recent-token budget. */ +export function findCutPoint( + entries: SessionTreeEntry[], + startIndex: number, + endIndex: number, + keepRecentTokens: number, +): CutPointResult { + const cutPoints = findValidCutPoints(entries, startIndex, endIndex); + + if (cutPoints.length === 0) { + return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false }; + } + let accumulatedTokens = 0; + let cutIndex = cutPoints[0]; + + for (let i = endIndex - 1; i >= startIndex; i--) { + const entry = entries[i]; + if (entry.type !== "message") continue; + const messageTokens = estimateTokens(entry.message as AgentMessage); + accumulatedTokens += messageTokens; + if (accumulatedTokens >= keepRecentTokens) { + for (let c = 0; c < cutPoints.length; c++) { + if (cutPoints[c] >= i) { + cutIndex = cutPoints[c]; + break; + } + } + break; + } + } + while (cutIndex > startIndex) { + const prevEntry = entries[cutIndex - 1]; + if (prevEntry.type === "compaction") { + break; + } + if (prevEntry.type === "message") { + break; + } + cutIndex--; + } + const cutEntry = entries[cutIndex]; + const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user"; + const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex); + + return { + firstKeptEntryIndex: cutIndex, + turnStartIndex, + isSplitTurn: !isUserMessage && turnStartIndex !== -1, + }; +} + +export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified. + +Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`; + +const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work. + +Use this EXACT format: + +## Goal +[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.] + +## Constraints & Preferences +- [Any constraints, preferences, or requirements mentioned by user] +- [Or "(none)" if none were mentioned] + +## Progress +### Done +- [x] [Completed tasks/changes] + +### In Progress +- [ ] [Current work] + +### Blocked +- [Issues preventing progress, if any] + +## Key Decisions +- **[Decision]**: [Brief rationale] + +## Next Steps +1. [Ordered list of what should happen next] + +## Critical Context +- [Any data, examples, or references needed to continue] +- [Or "(none)" if not applicable] + +Keep each section concise. Preserve exact file paths, function names, and error messages.`; + +const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate into the existing summary provided in <previous-summary> tags. + +Update the existing structured summary with new information. RULES: +- PRESERVE all existing information from the previous summary +- ADD new progress, decisions, and context from the new messages +- UPDATE the Progress section: move items from "In Progress" to "Done" when completed +- UPDATE "Next Steps" based on what was accomplished +- PRESERVE exact file paths, function names, and error messages +- If something is no longer relevant, you may remove it + +Use this EXACT format: + +## Goal +[Preserve existing goals, add new ones if the task expanded] + +## Constraints & Preferences +- [Preserve existing, add new ones discovered] + +## Progress +### Done +- [x] [Include previously done items AND newly completed items] + +### In Progress +- [ ] [Current work - update based on progress] + +### Blocked +- [Current blockers - remove if resolved] + +## Key Decisions +- **[Decision]**: [Brief rationale] (preserve all previous, add new) + +## Next Steps +1. [Update based on current state] + +## Critical Context +- [Preserve important context, add new if needed] + +Keep each section concise. Preserve exact file paths, function names, and error messages.`; + +/** Generate or update a conversation summary for compaction. */ +export async function generateSummary( + currentMessages: AgentMessage[], + model: Model<any>, + reserveTokens: number, + apiKey: string, + headers?: Record<string, string>, + signal?: AbortSignal, + customInstructions?: string, + previousSummary?: string, + thinkingLevel?: ThinkingLevel, +): Promise<Result<string, CompactionError>> { + const maxTokens = Math.min( + Math.floor(0.8 * reserveTokens), + model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, + ); + let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT; + if (customInstructions) { + basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`; + } + const llmMessages = convertToLlm(currentMessages); + const conversationText = serializeConversation(llmMessages); + let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`; + if (previousSummary) { + promptText += `<previous-summary>\n${previousSummary}\n</previous-summary>\n\n`; + } + promptText += basePrompt; + + const summarizationMessages = [ + { + role: "user" as const, + content: [{ type: "text" as const, text: promptText }], + timestamp: Date.now(), + }, + ]; + + const completionOptions = + model.reasoning && thinkingLevel && thinkingLevel !== "off" + ? { maxTokens, signal, apiKey, headers, reasoning: thinkingLevel } + : { maxTokens, signal, apiKey, headers }; + + const response = await completeSimple( + model, + { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, + completionOptions, + ); + if (response.stopReason === "aborted") { + return err(new CompactionError("aborted", response.errorMessage || "Summarization aborted")); + } + if (response.stopReason === "error") { + return err( + new CompactionError( + "summarization_failed", + `Summarization failed: ${response.errorMessage || "Unknown error"}`, + ), + ); + } + + const textContent = response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"); + + return ok(textContent); +} + +/** Prepared inputs for a compaction run. */ +export interface CompactionPreparation { + /** Entry id where retained history starts. */ + firstKeptEntryId: string; + /** Messages summarized into the history summary. */ + messagesToSummarize: AgentMessage[]; + /** Prefix messages summarized separately when compaction splits a turn. */ + turnPrefixMessages: AgentMessage[]; + /** Whether compaction splits a turn. */ + isSplitTurn: boolean; + /** Estimated context tokens before compaction. */ + tokensBefore: number; + /** Previous compaction summary used for iterative updates. */ + previousSummary?: string; + /** File operations extracted from summarized history. */ + fileOps: FileOperations; + /** Settings used to prepare compaction. */ + settings: CompactionSettings; +} + +/** Prepare session entries for compaction, or return undefined when compaction is not applicable. */ +export function prepareCompaction( + pathEntries: SessionTreeEntry[], + settings: CompactionSettings, +): Result<CompactionPreparation | undefined, CompactionError> { + if (pathEntries.length === 0 || pathEntries[pathEntries.length - 1].type === "compaction") { + return ok(undefined); + } + + let prevCompactionIndex = -1; + for (let i = pathEntries.length - 1; i >= 0; i--) { + if (pathEntries[i].type === "compaction") { + prevCompactionIndex = i; + break; + } + } + + let previousSummary: string | undefined; + let boundaryStart = 0; + if (prevCompactionIndex >= 0) { + const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry; + previousSummary = prevCompaction.summary; + const firstKeptEntryIndex = pathEntries.findIndex((entry) => entry.id === prevCompaction.firstKeptEntryId); + boundaryStart = firstKeptEntryIndex >= 0 ? firstKeptEntryIndex : prevCompactionIndex + 1; + } + const boundaryEnd = pathEntries.length; + + const tokensBefore = estimateContextTokens(buildSessionContext(pathEntries).messages).tokens; + + const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, settings.keepRecentTokens); + const firstKeptEntry = pathEntries[cutPoint.firstKeptEntryIndex]; + if (!firstKeptEntry?.id) { + return err(new CompactionError("invalid_session", "First kept entry has no UUID - session may need migration")); + } + const firstKeptEntryId = firstKeptEntry.id; + + const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex; + const messagesToSummarize: AgentMessage[] = []; + for (let i = boundaryStart; i < historyEnd; i++) { + const msg = getMessageFromEntryForCompaction(pathEntries[i]); + if (msg) messagesToSummarize.push(msg); + } + const turnPrefixMessages: AgentMessage[] = []; + if (cutPoint.isSplitTurn) { + for (let i = cutPoint.turnStartIndex; i < cutPoint.firstKeptEntryIndex; i++) { + const msg = getMessageFromEntryForCompaction(pathEntries[i]); + if (msg) turnPrefixMessages.push(msg); + } + } + const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex); + if (cutPoint.isSplitTurn) { + for (const msg of turnPrefixMessages) { + extractFileOpsFromMessage(msg, fileOps); + } + } + + return ok({ + firstKeptEntryId, + messagesToSummarize, + turnPrefixMessages, + isSplitTurn: cutPoint.isSplitTurn, + tokensBefore, + previousSummary, + fileOps, + settings, + }); +} + +const TURN_PREFIX_SUMMARIZATION_PROMPT = `This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained. + +Summarize the prefix to provide context for the retained suffix: + +## Original Request +[What did the user ask for in this turn?] + +## Early Progress +- [Key decisions and work done in the prefix] + +## Context for Suffix +- [Information needed to understand the retained recent work] + +Be concise. Focus on what's needed to understand the kept suffix.`; + +export { serializeConversation } from "./utils"; + +/** Generate compaction summary data from prepared session history. */ +export async function compact( + preparation: CompactionPreparation, + model: Model<any>, + apiKey: string, + headers?: Record<string, string>, + customInstructions?: string, + signal?: AbortSignal, + thinkingLevel?: ThinkingLevel, +): Promise<Result<CompactionResult, CompactionError>> { + const { + firstKeptEntryId, + messagesToSummarize, + turnPrefixMessages, + isSplitTurn, + tokensBefore, + previousSummary, + fileOps, + settings, + } = preparation; + + if (!firstKeptEntryId) { + return err(new CompactionError("invalid_session", "First kept entry has no UUID - session may need migration")); + } + + let summary: string; + + if (isSplitTurn && turnPrefixMessages.length > 0) { + const [historyResult, turnPrefixResult] = await Promise.all([ + messagesToSummarize.length > 0 + ? generateSummary( + messagesToSummarize, + model, + settings.reserveTokens, + apiKey, + headers, + signal, + customInstructions, + previousSummary, + thinkingLevel, + ) + : Promise.resolve(ok<string, CompactionError>("No prior history.")), + generateTurnPrefixSummary( + turnPrefixMessages, + model, + settings.reserveTokens, + apiKey, + headers, + signal, + thinkingLevel, + ), + ]); + if (!historyResult.ok) return err(historyResult.error); + if (!turnPrefixResult.ok) return err(turnPrefixResult.error); + summary = `${historyResult.value}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.value}`; + } else { + const summaryResult = await generateSummary( + messagesToSummarize, + model, + settings.reserveTokens, + apiKey, + headers, + signal, + customInstructions, + previousSummary, + thinkingLevel, + ); + if (!summaryResult.ok) return err(summaryResult.error); + summary = summaryResult.value; + } + + const { readFiles, modifiedFiles } = computeFileLists(fileOps); + summary += formatFileOperations(readFiles, modifiedFiles); + + return ok({ + summary, + firstKeptEntryId, + tokensBefore, + details: { readFiles, modifiedFiles } as CompactionDetails, + }); +} +async function generateTurnPrefixSummary( + messages: AgentMessage[], + model: Model<any>, + reserveTokens: number, + apiKey: string, + headers?: Record<string, string>, + signal?: AbortSignal, + thinkingLevel?: ThinkingLevel, +): Promise<Result<string, CompactionError>> { + const maxTokens = Math.min( + Math.floor(0.5 * reserveTokens), + model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, + ); + const llmMessages = convertToLlm(messages); + const conversationText = serializeConversation(llmMessages); + const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`; + const summarizationMessages = [ + { + role: "user" as const, + content: [{ type: "text" as const, text: promptText }], + timestamp: Date.now(), + }, + ]; + + const response = await completeSimple( + model, + { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, + model.reasoning && thinkingLevel && thinkingLevel !== "off" + ? { maxTokens, signal, apiKey, headers, reasoning: thinkingLevel } + : { maxTokens, signal, apiKey, headers }, + ); + if (response.stopReason === "aborted") { + return err(new CompactionError("aborted", response.errorMessage || "Turn prefix summarization aborted")); + } + if (response.stopReason === "error") { + return err( + new CompactionError( + "summarization_failed", + `Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`, + ), + ); + } + + return ok( + response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"), + ); +} diff --git a/emain/agent/harness/compaction/utils.ts b/emain/agent/harness/compaction/utils.ts new file mode 100644 index 000000000..763e55f6f --- /dev/null +++ b/emain/agent/harness/compaction/utils.ts @@ -0,0 +1,144 @@ +import type { Message } from "../../../ai"; +import type { AgentMessage } from "../../types"; + +/** File paths touched by a session branch or compaction range. */ +export interface FileOperations { + /** Files read but not necessarily modified. */ + read: Set<string>; + /** Files written by full-file write operations. */ + written: Set<string>; + /** Files modified by edit operations. */ + edited: Set<string>; +} + +/** Create an empty file-operation accumulator. */ +export function createFileOps(): FileOperations { + return { + read: new Set(), + written: new Set(), + edited: new Set(), + }; +} + +/** Add file operations from assistant tool calls to an accumulator. */ +export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void { + if (message.role !== "assistant") return; + if (!("content" in message) || !Array.isArray(message.content)) return; + + for (const block of message.content) { + if (typeof block !== "object" || block === null) continue; + if (!("type" in block) || block.type !== "toolCall") continue; + if (!("arguments" in block) || !("name" in block)) continue; + + const args = block.arguments as Record<string, unknown> | undefined; + if (!args) continue; + + const path = typeof args.path === "string" ? args.path : undefined; + if (!path) continue; + + switch (block.name) { + case "read": + fileOps.read.add(path); + break; + case "write": + fileOps.written.add(path); + break; + case "edit": + fileOps.edited.add(path); + break; + } + } +} + +/** Compute sorted read-only and modified file lists from accumulated operations. */ +export function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] } { + const modified = new Set([...fileOps.edited, ...fileOps.written]); + const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).sort(); + const modifiedFiles = [...modified].sort(); + return { readFiles: readOnly, modifiedFiles }; +} + +/** Format file lists as summary metadata tags. */ +export function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string { + const sections: string[] = []; + if (readFiles.length > 0) { + sections.push(`<read-files>\n${readFiles.join("\n")}\n</read-files>`); + } + if (modifiedFiles.length > 0) { + sections.push(`<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`); + } + if (sections.length === 0) return ""; + return `\n\n${sections.join("\n\n")}`; +} + +const TOOL_RESULT_MAX_CHARS = 2000; + +function safeJsonStringify(value: unknown): string { + try { + return JSON.stringify(value) ?? "undefined"; + } catch { + return "[unserializable]"; + } +} + +function truncateForSummary(text: string, maxChars: number): string { + if (text.length <= maxChars) return text; + const truncatedChars = text.length - maxChars; + return `${text.slice(0, maxChars)}\n\n[... ${truncatedChars} more characters truncated]`; +} + +/** Serialize LLM messages to plain text for summarization prompts. */ +export function serializeConversation(messages: Message[]): string { + const parts: string[] = []; + + for (const msg of messages) { + if (msg.role === "user") { + const content = + typeof msg.content === "string" + ? msg.content + : msg.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + if (content) parts.push(`[User]: ${content}`); + } else if (msg.role === "assistant") { + const textParts: string[] = []; + const thinkingParts: string[] = []; + const toolCalls: string[] = []; + + for (const block of msg.content) { + if (block.type === "text") { + textParts.push(block.text); + } else if (block.type === "thinking") { + thinkingParts.push(block.thinking); + } else if (block.type === "toolCall") { + const args = block.arguments as Record<string, unknown>; + const argsStr = Object.entries(args) + .map(([k, v]) => `${k}=${safeJsonStringify(v)}`) + .join(", "); + toolCalls.push(`${block.name}(${argsStr})`); + } + } + + if (thinkingParts.length > 0) { + parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`); + } + if (textParts.length > 0) { + parts.push(`[Assistant]: ${textParts.join("\n")}`); + } + if (toolCalls.length > 0) { + parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`); + } + } else if (msg.role === "toolResult") { + const content = msg.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + if (content) { + parts.push(`[Tool result]: ${truncateForSummary(content, TOOL_RESULT_MAX_CHARS)}`); + } + } + } + + return parts.join("\n\n"); +} diff --git a/emain/agent/harness/env/nodejs.ts b/emain/agent/harness/env/nodejs.ts new file mode 100644 index 000000000..03ac84fdc --- /dev/null +++ b/emain/agent/harness/env/nodejs.ts @@ -0,0 +1,531 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { constants, createReadStream } from "node:fs"; +import { + access, + appendFile, + lstat, + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { isAbsolute, join, resolve } from "node:path"; +import { createInterface } from "node:readline"; +import { + type ExecutionEnv, + ExecutionError, + err, + FileError, + type FileInfo, + type FileKind, + ok, + type Result, + toError, +} from "../types"; + +function resolvePath(cwd: string, path: string): string { + return isAbsolute(path) ? path : resolve(cwd, path); +} + +function fileKindFromStats(stats: { + isFile(): boolean; + isDirectory(): boolean; + isSymbolicLink(): boolean; +}): FileKind | undefined { + if (stats.isFile()) return "file"; + if (stats.isDirectory()) return "directory"; + if (stats.isSymbolicLink()) return "symlink"; + return undefined; +} + +function fileInfoFromStats( + path: string, + stats: { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean; size: number; mtimeMs: number }, +): Result<FileInfo, FileError> { + const kind = fileKindFromStats(stats); + if (!kind) return err(new FileError("invalid", "Unsupported file type", path)); + return ok({ + name: path.replace(/\/+$/, "").split("/").pop() ?? path, + path, + kind, + size: stats.size, + mtimeMs: stats.mtimeMs, + }); +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} + +function toFileError(error: unknown, path?: string): FileError { + if (error instanceof FileError) return error; + const cause = toError(error); + if (isNodeError(error)) { + const message = error.message; + switch (error.code) { + case "ABORT_ERR": + return new FileError("aborted", message, path, cause); + case "ENOENT": + return new FileError("not_found", message, path, cause); + case "EACCES": + case "EPERM": + return new FileError("permission_denied", message, path, cause); + case "ENOTDIR": + return new FileError("not_directory", message, path, cause); + case "EISDIR": + return new FileError("is_directory", message, path, cause); + case "EINVAL": + return new FileError("invalid", message, path, cause); + } + } + return new FileError("unknown", cause.message, path, cause); +} + +function abortResult<TValue>(signal: AbortSignal | undefined, path?: string): Result<TValue, FileError> | undefined { + return signal?.aborted ? err(new FileError("aborted", "aborted", path)) : undefined; +} + +async function pathExists(path: string): Promise<boolean> { + try { + await access(path, constants.F_OK); + return true; + } catch { + return false; + } +} + +async function runCommand( + command: string, + args: string[], + timeoutMs: number, +): Promise<{ stdout: string; status: number | null }> { + return await new Promise((resolve) => { + let stdout = ""; + let child: ReturnType<typeof spawn>; + try { + child = spawn(command, args, { + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, + }); + } catch { + resolve({ stdout: "", status: null }); + return; + } + const timeout = setTimeout(() => { + if (child.pid) killProcessTree(child.pid); + }, timeoutMs); + child.stdout?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => { + stdout += chunk; + }); + child.on("error", () => { + clearTimeout(timeout); + resolve({ stdout: "", status: null }); + }); + child.on("close", (status) => { + clearTimeout(timeout); + resolve({ stdout, status }); + }); + }); +} + +async function findBashOnPath(): Promise<string | null> { + const result = + process.platform === "win32" + ? await runCommand("where", ["bash.exe"], 5000) + : await runCommand("which", ["bash"], 5000); + if (result.status !== 0 || !result.stdout) return null; + const firstMatch = result.stdout.trim().split(/\r?\n/)[0]; + return firstMatch && (await pathExists(firstMatch)) ? firstMatch : null; +} + +async function getShellConfig( + customShellPath?: string, +): Promise<Result<{ shell: string; args: string[] }, ExecutionError>> { + if (customShellPath) { + if (await pathExists(customShellPath)) { + return ok({ shell: customShellPath, args: ["-c"] }); + } + return err(new ExecutionError("shell_unavailable", `Custom shell path not found: ${customShellPath}`)); + } + if (process.platform === "win32") { + const candidates: string[] = []; + const programFiles = process.env.ProgramFiles; + if (programFiles) candidates.push(`${programFiles}\\Git\\bin\\bash.exe`); + const programFilesX86 = process.env["ProgramFiles(x86)"]; + if (programFilesX86) candidates.push(`${programFilesX86}\\Git\\bin\\bash.exe`); + for (const candidate of candidates) { + if (await pathExists(candidate)) { + return ok({ shell: candidate, args: ["-c"] }); + } + } + const bashOnPath = await findBashOnPath(); + if (bashOnPath) { + return ok({ shell: bashOnPath, args: ["-c"] }); + } + return err(new ExecutionError("shell_unavailable", "No bash shell found")); + } + + if (await pathExists("/bin/bash")) { + return ok({ shell: "/bin/bash", args: ["-c"] }); + } + const bashOnPath = await findBashOnPath(); + if (bashOnPath) { + return ok({ shell: bashOnPath, args: ["-c"] }); + } + return ok({ shell: "sh", args: ["-c"] }); +} + +function getShellEnv(baseEnv?: NodeJS.ProcessEnv, extraEnv?: Record<string, string>): NodeJS.ProcessEnv { + return { + ...process.env, + ...baseEnv, + ...extraEnv, + }; +} + +function killProcessTree(pid: number): void { + if (process.platform === "win32") { + try { + spawn("taskkill", ["/F", "/T", "/PID", String(pid)], { + stdio: "ignore", + detached: true, + windowsHide: true, + }); + } catch { + // Ignore errors. + } + return; + } + + try { + process.kill(-pid, "SIGKILL"); + } catch { + try { + process.kill(pid, "SIGKILL"); + } catch { + // Process already dead. + } + } +} + +export class NodeExecutionEnv implements ExecutionEnv { + cwd: string; + private shellPath?: string; + private shellEnv?: NodeJS.ProcessEnv; + + constructor(options: { cwd: string; shellPath?: string; shellEnv?: NodeJS.ProcessEnv }) { + this.cwd = options.cwd; + this.shellPath = options.shellPath; + this.shellEnv = options.shellEnv; + } + + async absolutePath(path: string): Promise<Result<string, FileError>> { + return ok(resolvePath(this.cwd, path)); + } + + async joinPath(parts: string[]): Promise<Result<string, FileError>> { + return ok(join(...parts)); + } + + async exec( + command: string, + options?: { + cwd?: string; + env?: Record<string, string>; + timeout?: number; + abortSignal?: AbortSignal; + onStdout?: (chunk: string) => void; + onStderr?: (chunk: string) => void; + }, + ): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>> { + if (options?.abortSignal?.aborted) return err(new ExecutionError("aborted", "aborted")); + + const cwd = options?.cwd ? resolvePath(this.cwd, options.cwd) : this.cwd; + const shellConfig = await getShellConfig(this.shellPath); + // Cast: crest flattened Result<T,E> so TS can't propagate the + // {ok:false, error:E} branch directly into a Result<U,E> return. + // Runtime semantics are identical. + if (!shellConfig.ok) return shellConfig as unknown as Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>; + + return await new Promise((resolvePromise) => { + let stdout = ""; + let stderr = ""; + let settled = false; + let timedOut = false; + let callbackError: ExecutionError | undefined; + let child: ReturnType<typeof spawn> | undefined; + let timeoutId: ReturnType<typeof setTimeout> | undefined; + + const onAbort = () => { + if (child?.pid) { + killProcessTree(child.pid); + } + }; + + const settle = (result: Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>) => { + if (timeoutId) clearTimeout(timeoutId); + if (options?.abortSignal) options.abortSignal.removeEventListener("abort", onAbort); + if (settled) return; + settled = true; + resolvePromise(result); + }; + + try { + child = spawn(shellConfig.value.shell, [...shellConfig.value.args, command], { + cwd, + detached: process.platform !== "win32", + env: getShellEnv(this.shellEnv, options?.env), + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + } catch (error) { + const cause = toError(error); + settle(err(new ExecutionError("spawn_error", cause.message, cause))); + return; + } + + timeoutId = + typeof options?.timeout === "number" + ? setTimeout(() => { + timedOut = true; + if (child?.pid) { + killProcessTree(child.pid); + } + }, options.timeout * 1000) + : undefined; + + if (options?.abortSignal) { + if (options.abortSignal.aborted) { + onAbort(); + } else { + options.abortSignal.addEventListener("abort", onAbort, { once: true }); + } + } + + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => { + stdout += chunk; + try { + options?.onStdout?.(chunk); + } catch (error) { + const cause = toError(error); + callbackError = new ExecutionError("callback_error", cause.message, cause); + onAbort(); + } + }); + child.stderr?.on("data", (chunk: string) => { + stderr += chunk; + try { + options?.onStderr?.(chunk); + } catch (error) { + const cause = toError(error); + callbackError = new ExecutionError("callback_error", cause.message, cause); + onAbort(); + } + }); + + child.on("error", (error) => { + settle(err(new ExecutionError("spawn_error", error.message, error))); + }); + + child.on("close", (code) => { + if (callbackError) { + settle(err(callbackError)); + return; + } + if (timedOut) { + settle(err(new ExecutionError("timeout", `timeout:${options?.timeout}`))); + return; + } + if (options?.abortSignal?.aborted) { + settle(err(new ExecutionError("aborted", "aborted"))); + return; + } + settle(ok({ stdout, stderr, exitCode: code ?? 0 })); + }); + }); + } + + async readTextFile(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>> { + const resolved = resolvePath(this.cwd, path); + const aborted = abortResult<string>(abortSignal, resolved); + if (aborted) return aborted; + try { + return ok(await readFile(resolved, { encoding: "utf8", signal: abortSignal })); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async readTextLines( + path: string, + options?: { maxLines?: number; abortSignal?: AbortSignal }, + ): Promise<Result<string[], FileError>> { + const resolved = resolvePath(this.cwd, path); + const aborted = abortResult<string[]>(options?.abortSignal, resolved); + if (aborted) return aborted; + if (options?.maxLines !== undefined && options.maxLines <= 0) return ok([]); + let stream: ReturnType<typeof createReadStream> | undefined; + let lineReader: ReturnType<typeof createInterface> | undefined; + try { + stream = createReadStream(resolved, { encoding: "utf8", signal: options?.abortSignal }); + lineReader = createInterface({ input: stream, crlfDelay: Infinity }); + const lines: string[] = []; + for await (const line of lineReader) { + const loopAbort = abortResult<string[]>(options?.abortSignal, resolved); + if (loopAbort) return loopAbort; + lines.push(line); + if (options?.maxLines !== undefined && lines.length >= options.maxLines) break; + } + const afterReadAbort = abortResult<string[]>(options?.abortSignal, resolved); + if (afterReadAbort) return afterReadAbort; + return ok(lines); + } catch (error) { + return err(toFileError(error, resolved)); + } finally { + lineReader?.close(); + stream?.destroy(); + } + } + + async readBinaryFile(path: string, abortSignal?: AbortSignal): Promise<Result<Uint8Array, FileError>> { + const resolved = resolvePath(this.cwd, path); + const aborted = abortResult<Uint8Array>(abortSignal, resolved); + if (aborted) return aborted; + try { + return ok(await readFile(resolved, { signal: abortSignal })); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async writeFile( + path: string, + content: string | Uint8Array, + abortSignal?: AbortSignal, + ): Promise<Result<void, FileError>> { + const resolved = resolvePath(this.cwd, path); + const aborted = abortResult<void>(abortSignal, resolved); + if (aborted) return aborted; + try { + await mkdir(resolve(resolved, ".."), { recursive: true }); + const afterMkdirAbort = abortResult<void>(abortSignal, resolved); + if (afterMkdirAbort) return afterMkdirAbort; + await writeFile(resolved, content, { signal: abortSignal }); + return ok(undefined); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async appendFile(path: string, content: string | Uint8Array): Promise<Result<void, FileError>> { + const resolved = resolvePath(this.cwd, path); + try { + await mkdir(resolve(resolved, ".."), { recursive: true }); + await appendFile(resolved, content); + return ok(undefined); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async fileInfo(path: string): Promise<Result<FileInfo, FileError>> { + const resolved = resolvePath(this.cwd, path); + try { + return fileInfoFromStats(resolved, await lstat(resolved)); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async listDir(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>> { + const resolved = resolvePath(this.cwd, path); + const aborted = abortResult<FileInfo[]>(abortSignal, resolved); + if (aborted) return aborted; + try { + const entries = await readdir(resolved, { withFileTypes: true }); + const infos: FileInfo[] = []; + for (const entry of entries) { + const loopAbort = abortResult<FileInfo[]>(abortSignal, resolved); + if (loopAbort) return loopAbort; + const entryPath = resolve(resolved, entry.name); + try { + const info = fileInfoFromStats(entryPath, await lstat(entryPath)); + if (info.ok) infos.push(info.value); + } catch (error) { + return err(toFileError(error, entryPath)); + } + } + return ok(infos); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async canonicalPath(path: string): Promise<Result<string, FileError>> { + const resolved = resolvePath(this.cwd, path); + try { + return ok(await realpath(resolved)); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async exists(path: string): Promise<Result<boolean, FileError>> { + const result = await this.fileInfo(path); + if (result.ok) return ok(true); + if (result.error.code === "not_found") return ok(false); + return err(result.error); + } + + async createDir(path: string, options?: { recursive?: boolean }): Promise<Result<void, FileError>> { + const resolved = resolvePath(this.cwd, path); + try { + await mkdir(resolved, { recursive: options?.recursive ?? true }); + return ok(undefined); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async remove(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<Result<void, FileError>> { + const resolved = resolvePath(this.cwd, path); + try { + await rm(resolved, { recursive: options?.recursive ?? false, force: options?.force ?? false }); + return ok(undefined); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async createTempDir(prefix: string = "tmp-"): Promise<Result<string, FileError>> { + try { + return ok(await mkdtemp(join(tmpdir(), prefix))); + } catch (error) { + return err(toFileError(error)); + } + } + + async createTempFile(options?: { prefix?: string; suffix?: string }): Promise<Result<string, FileError>> { + const dir = await this.createTempDir("tmp-"); + if (!dir.ok) return dir; + const filePath = join(dir.value, `${options?.prefix ?? ""}${randomUUID()}${options?.suffix ?? ""}`); + try { + await writeFile(filePath, ""); + return ok(filePath); + } catch (error) { + return err(toFileError(error, filePath)); + } + } + + async cleanup(): Promise<void> { + // nothing to clean up for the local node implementation + } +} diff --git a/emain/agent/harness/messages.ts b/emain/agent/harness/messages.ts new file mode 100644 index 000000000..5a9cd1a6e --- /dev/null +++ b/emain/agent/harness/messages.ts @@ -0,0 +1,164 @@ +import type { ImageContent, Message, TextContent } from "../../ai"; +import type { AgentMessage } from "../types"; + +export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary: + +<summary> +`; + +export const COMPACTION_SUMMARY_SUFFIX = ` +</summary>`; + +export const BRANCH_SUMMARY_PREFIX = `The following is a summary of a branch that this conversation came back from: + +<summary> +`; + +export const BRANCH_SUMMARY_SUFFIX = `</summary>`; + +export interface BashExecutionMessage { + role: "bashExecution"; + command: string; + output: string; + exitCode: number | undefined; + cancelled: boolean; + truncated: boolean; + fullOutputPath?: string; + timestamp: number; + excludeFromContext?: boolean; +} + +export interface CustomMessage<T = unknown> { + role: "custom"; + customType: string; + content: string | (TextContent | ImageContent)[]; + display: boolean; + details?: T; + timestamp: number; +} + +export interface BranchSummaryMessage { + role: "branchSummary"; + summary: string; + fromId: string; + timestamp: number; +} + +export interface CompactionSummaryMessage { + role: "compactionSummary"; + summary: string; + tokensBefore: number; + timestamp: number; +} + +declare module "../types" { + interface CustomAgentMessages { + bashExecution: BashExecutionMessage; + custom: CustomMessage; + branchSummary: BranchSummaryMessage; + compactionSummary: CompactionSummaryMessage; + } +} + +export function bashExecutionToText(msg: BashExecutionMessage): string { + let text = `Ran \`${msg.command}\`\n`; + if (msg.output) { + text += `\`\`\`\n${msg.output}\n\`\`\``; + } else { + text += "(no output)"; + } + if (msg.cancelled) { + text += "\n\n(command cancelled)"; + } else if (msg.exitCode !== null && msg.exitCode !== undefined && msg.exitCode !== 0) { + text += `\n\nCommand exited with code ${msg.exitCode}`; + } + if (msg.truncated && msg.fullOutputPath) { + text += `\n\n[Output truncated. Full output: ${msg.fullOutputPath}]`; + } + return text; +} + +export function createBranchSummaryMessage(summary: string, fromId: string, timestamp: string): BranchSummaryMessage { + return { + role: "branchSummary", + summary, + fromId, + timestamp: new Date(timestamp).getTime(), + }; +} + +export function createCompactionSummaryMessage( + summary: string, + tokensBefore: number, + timestamp: string, +): CompactionSummaryMessage { + return { + role: "compactionSummary", + summary, + tokensBefore, + timestamp: new Date(timestamp).getTime(), + }; +} + +export function createCustomMessage( + customType: string, + content: string | (TextContent | ImageContent)[], + display: boolean, + details: unknown | undefined, + timestamp: string, +): CustomMessage { + return { + role: "custom", + customType, + content, + display, + details, + timestamp: new Date(timestamp).getTime(), + }; +} + +export function convertToLlm(messages: AgentMessage[]): Message[] { + return messages + .map((m): Message | undefined => { + switch (m.role) { + case "bashExecution": + if (m.excludeFromContext) { + return undefined; + } + return { + role: "user", + content: [{ type: "text", text: bashExecutionToText(m) }], + timestamp: m.timestamp, + }; + case "custom": { + const content = typeof m.content === "string" ? [{ type: "text" as const, text: m.content }] : m.content; + return { + role: "user", + content, + timestamp: m.timestamp, + }; + } + case "branchSummary": + return { + role: "user", + content: [{ type: "text" as const, text: BRANCH_SUMMARY_PREFIX + m.summary + BRANCH_SUMMARY_SUFFIX }], + timestamp: m.timestamp, + }; + case "compactionSummary": + return { + role: "user", + content: [ + { type: "text" as const, text: COMPACTION_SUMMARY_PREFIX + m.summary + COMPACTION_SUMMARY_SUFFIX }, + ], + timestamp: m.timestamp, + }; + case "user": + case "assistant": + case "toolResult": + return m; + default: + return undefined; + } + }) + .filter((m): m is Message => m !== undefined); +} diff --git a/emain/agent/harness/prompt-templates.ts b/emain/agent/harness/prompt-templates.ts new file mode 100644 index 000000000..d92a27788 --- /dev/null +++ b/emain/agent/harness/prompt-templates.ts @@ -0,0 +1,267 @@ +import { parse } from "yaml"; +import { type ExecutionEnv, type FileInfo, type PromptTemplate, type Result, toError } from "./types"; + +export type PromptTemplateDiagnosticCode = "file_info_failed" | "list_failed" | "read_failed" | "parse_failed"; + +/** Warning produced while loading prompt templates. */ +export interface PromptTemplateDiagnostic { + /** Diagnostic severity. Currently only warnings are emitted. */ + type: "warning"; + /** Stable diagnostic code. */ + code: PromptTemplateDiagnosticCode; + /** Human-readable diagnostic message. */ + message: string; + /** Path associated with the diagnostic. */ + path: string; +} + +interface PromptTemplateFrontmatter { + description?: string; + "argument-hint"?: string; + [key: string]: unknown; +} + +/** + * Load prompt templates from one or more paths. + * + * Directory inputs load direct `.md` children non-recursively. File inputs load explicit `.md` files. Missing paths and + * non-markdown files are skipped. Read and parse failures are returned as diagnostics. + */ +export async function loadPromptTemplates( + env: ExecutionEnv, + paths: string | string[], +): Promise<{ promptTemplates: PromptTemplate[]; diagnostics: PromptTemplateDiagnostic[] }> { + const promptTemplates: PromptTemplate[] = []; + const diagnostics: PromptTemplateDiagnostic[] = []; + for (const path of Array.isArray(paths) ? paths : [paths]) { + const infoResult = await env.fileInfo(path); + if (!infoResult.ok) { + if (infoResult.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: infoResult.error.message, + path, + }); + } + continue; + } + const info = infoResult.value; + const kind = await resolveKind(env, info, diagnostics); + if (kind === "directory") { + const result = await loadTemplatesFromDir(env, info.path); + promptTemplates.push(...result.promptTemplates); + diagnostics.push(...result.diagnostics); + } else if (kind === "file" && info.name.endsWith(".md")) { + const result = await loadTemplateFromFile(env, info.path); + if (result.promptTemplate) promptTemplates.push(result.promptTemplate); + diagnostics.push(...result.diagnostics); + } + } + return { promptTemplates, diagnostics }; +} + +/** + * Load prompt templates from source-tagged paths. + * + * Source values are preserved exactly and attached to every loaded prompt template and diagnostic. The agent package does + * not interpret source values; applications define their own provenance shape. + */ +export async function loadSourcedPromptTemplates<TSource, TPromptTemplate extends PromptTemplate = PromptTemplate>( + env: ExecutionEnv, + inputs: Array<{ path: string; source: TSource }>, + mapPromptTemplate?: (promptTemplate: PromptTemplate, source: TSource) => TPromptTemplate, +): Promise<{ + promptTemplates: Array<{ promptTemplate: TPromptTemplate; source: TSource }>; + diagnostics: Array<PromptTemplateDiagnostic & { source: TSource }>; +}> { + const promptTemplates: Array<{ promptTemplate: TPromptTemplate; source: TSource }> = []; + const diagnostics: Array<PromptTemplateDiagnostic & { source: TSource }> = []; + for (const input of inputs) { + const result = await loadPromptTemplates(env, input.path); + for (const promptTemplate of result.promptTemplates) { + promptTemplates.push({ + promptTemplate: mapPromptTemplate + ? mapPromptTemplate(promptTemplate, input.source) + : (promptTemplate as TPromptTemplate), + source: input.source, + }); + } + for (const diagnostic of result.diagnostics) diagnostics.push({ ...diagnostic, source: input.source }); + } + return { promptTemplates, diagnostics }; +} + +async function loadTemplatesFromDir( + env: ExecutionEnv, + dir: string, +): Promise<{ promptTemplates: PromptTemplate[]; diagnostics: PromptTemplateDiagnostic[] }> { + const promptTemplates: PromptTemplate[] = []; + const diagnostics: PromptTemplateDiagnostic[] = []; + const entriesResult = await env.listDir(dir); + if (!entriesResult.ok) { + diagnostics.push({ + type: "warning", + code: "list_failed", + message: entriesResult.error.message, + path: dir, + }); + return { promptTemplates, diagnostics }; + } + const entries = entriesResult.value; + + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + const kind = await resolveKind(env, entry, diagnostics); + if (kind !== "file" || !entry.name.endsWith(".md")) continue; + const result = await loadTemplateFromFile(env, entry.path); + if (result.promptTemplate) promptTemplates.push(result.promptTemplate); + diagnostics.push(...result.diagnostics); + } + return { promptTemplates, diagnostics }; +} + +async function loadTemplateFromFile( + env: ExecutionEnv, + filePath: string, +): Promise<{ promptTemplate: PromptTemplate | null; diagnostics: PromptTemplateDiagnostic[] }> { + const diagnostics: PromptTemplateDiagnostic[] = []; + const rawContent = await env.readTextFile(filePath); + if (!rawContent.ok) { + diagnostics.push({ + type: "warning", + code: "read_failed", + message: rawContent.error.message, + path: filePath, + }); + return { promptTemplate: null, diagnostics }; + } + + const parsed = parseFrontmatter<PromptTemplateFrontmatter>(rawContent.value); + if (!parsed.ok) { + diagnostics.push({ + type: "warning", + code: "parse_failed", + message: parsed.error.message, + path: filePath, + }); + return { promptTemplate: null, diagnostics }; + } + + const { frontmatter, body } = parsed.value; + const firstLine = body.split("\n").find((line) => line.trim()); + let description = typeof frontmatter.description === "string" ? frontmatter.description : ""; + if (!description && firstLine) { + description = firstLine.slice(0, 60); + if (firstLine.length > 60) description += "..."; + } + return { + promptTemplate: { + name: basenameEnvPath(filePath).replace(/\.md$/i, ""), + description, + content: body, + }, + diagnostics, + }; +} + +async function resolveKind( + env: ExecutionEnv, + info: FileInfo, + diagnostics: PromptTemplateDiagnostic[], +): Promise<"file" | "directory" | undefined> { + if (info.kind === "file" || info.kind === "directory") return info.kind; + const canonicalPath = await env.canonicalPath(info.path); + if (!canonicalPath.ok) { + if (canonicalPath.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: canonicalPath.error.message, + path: info.path, + }); + } + return undefined; + } + const target = await env.fileInfo(canonicalPath.value); + if (!target.ok) { + if (target.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: target.error.message, + path: info.path, + }); + } + return undefined; + } + return target.value.kind === "file" || target.value.kind === "directory" ? target.value.kind : undefined; +} + +function parseFrontmatter<T extends Record<string, unknown>>( + content: string, +): Result<{ frontmatter: T; body: string }, Error> { + try { + const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (!normalized.startsWith("---")) return { ok: true, value: { frontmatter: {} as T, body: normalized } }; + const endIndex = normalized.indexOf("\n---", 3); + if (endIndex === -1) return { ok: true, value: { frontmatter: {} as T, body: normalized } }; + const yamlString = normalized.slice(4, endIndex); + const body = normalized.slice(endIndex + 4).trim(); + return { ok: true, value: { frontmatter: (parse(yamlString) ?? {}) as T, body } }; + } catch (error) { + return { ok: false, error: toError(error) }; + } +} + +function basenameEnvPath(path: string): string { + const normalized = path.replace(/\/+$/, ""); + const slashIndex = normalized.lastIndexOf("/"); + return slashIndex === -1 ? normalized : normalized.slice(slashIndex + 1); +} + +/** Parse an argument string using simple shell-style single and double quotes. */ +export function parseCommandArgs(argsString: string): string[] { + const args: string[] = []; + let current = ""; + let inQuote: string | null = null; + + for (let i = 0; i < argsString.length; i++) { + const char = argsString[i]!; + if (inQuote) { + if (char === inQuote) inQuote = null; + else current += char; + } else if (char === '"' || char === "'") { + inQuote = char; + } else if (char === " " || char === "\t") { + if (current) { + args.push(current); + current = ""; + } + } else { + current += char; + } + } + if (current) args.push(current); + return args; +} + +/** Substitute prompt template placeholders (`$1`, `$@`, `$ARGUMENTS`, `${@:N}`, `${@:N:L}`) with command arguments. */ +export function substituteArgs(content: string, args: string[]): string { + let result = content; + result = result.replace(/\$(\d+)/g, (_, num: string) => args[parseInt(num, 10) - 1] ?? ""); + result = result.replace(/\$\{@:(\d+)(?::(\d+))?\}/g, (_, startStr: string, lengthStr?: string) => { + let start = parseInt(startStr, 10) - 1; + if (start < 0) start = 0; + if (lengthStr) return args.slice(start, start + parseInt(lengthStr, 10)).join(" "); + return args.slice(start).join(" "); + }); + const allArgs = args.join(" "); + result = result.replace(/\$ARGUMENTS/g, allArgs); + result = result.replace(/\$@/g, allArgs); + return result; +} + +/** Format a prompt template invocation with positional arguments. */ +export function formatPromptTemplateInvocation(template: PromptTemplate, args: string[] = []): string { + return substituteArgs(template.content, args); +} diff --git a/emain/agent/harness/session/jsonl-repo.ts b/emain/agent/harness/session/jsonl-repo.ts new file mode 100644 index 000000000..f848a4aad --- /dev/null +++ b/emain/agent/harness/session/jsonl-repo.ts @@ -0,0 +1,177 @@ +import type { + FileSystem, + JsonlSessionCreateOptions, + JsonlSessionListOptions, + JsonlSessionMetadata, + JsonlSessionRepoApi, + Session, +} from "../types"; +import { SessionError, toError } from "../types"; +import { JsonlSessionStorage, loadJsonlSessionMetadata } from "./jsonl-storage"; +import { + createSessionId, + createTimestamp, + getEntriesToFork, + getFileSystemResultOrThrow, + toSession, +} from "./repo-utils"; + +type JsonlSessionRepoFileSystem = Pick< + FileSystem, + | "cwd" + | "absolutePath" + | "joinPath" + | "readTextFile" + | "readTextLines" + | "writeFile" + | "appendFile" + | "listDir" + | "exists" + | "createDir" + | "remove" +>; + +function encodeCwd(cwd: string): string { + return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; +} + +export class JsonlSessionRepo implements JsonlSessionRepoApi { + private readonly fs: JsonlSessionRepoFileSystem; + private readonly sessionsRootInput: string; + private sessionsRoot: string | undefined; + + constructor(options: { fs: JsonlSessionRepoFileSystem; sessionsRoot: string }) { + this.fs = options.fs; + this.sessionsRootInput = options.sessionsRoot; + } + + private async getSessionsRoot(): Promise<string> { + if (!this.sessionsRoot) { + this.sessionsRoot = getFileSystemResultOrThrow( + await this.fs.absolutePath(this.sessionsRootInput), + `Failed to resolve sessions root ${this.sessionsRootInput}`, + ); + } + return this.sessionsRoot; + } + + private async getSessionDir(cwd: string): Promise<string> { + return getFileSystemResultOrThrow( + await this.fs.joinPath([await this.getSessionsRoot(), encodeCwd(cwd)]), + `Failed to resolve session directory for ${cwd}`, + ); + } + + private async createSessionFilePath(cwd: string, sessionId: string, timestamp: string): Promise<string> { + return getFileSystemResultOrThrow( + await this.fs.joinPath([ + await this.getSessionDir(cwd), + `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`, + ]), + `Failed to resolve session file path for ${sessionId}`, + ); + } + + async create(options: JsonlSessionCreateOptions): Promise<Session<JsonlSessionMetadata>> { + const id = options.id ?? createSessionId(); + const createdAt = createTimestamp(); + const sessionDir = await this.getSessionDir(options.cwd); + getFileSystemResultOrThrow( + await this.fs.createDir(sessionDir, { recursive: true }), + `Failed to create session directory ${sessionDir}`, + ); + const filePath = await this.createSessionFilePath(options.cwd, id, createdAt); + const storage = await JsonlSessionStorage.create(this.fs, filePath, { + cwd: options.cwd, + sessionId: id, + parentSessionPath: options.parentSessionPath, + }); + return toSession(storage); + } + + async open(metadata: JsonlSessionMetadata): Promise<Session<JsonlSessionMetadata>> { + if ( + !getFileSystemResultOrThrow(await this.fs.exists(metadata.path), `Failed to check session ${metadata.path}`) + ) { + throw new SessionError("not_found", `Session not found: ${metadata.path}`); + } + const storage = await JsonlSessionStorage.open(this.fs, metadata.path); + return toSession(storage); + } + + async list(options: JsonlSessionListOptions = {}): Promise<JsonlSessionMetadata[]> { + const dirs = options.cwd ? [await this.getSessionDir(options.cwd)] : await this.listSessionDirs(); + const sessions: JsonlSessionMetadata[] = []; + for (const dir of dirs) { + if (!getFileSystemResultOrThrow(await this.fs.exists(dir), `Failed to check session directory ${dir}`)) { + continue; + } + const files = getFileSystemResultOrThrow( + await this.fs.listDir(dir), + `Failed to list sessions in ${dir}`, + ).filter((file) => file.kind !== "directory" && file.name.endsWith(".jsonl")); + for (const file of files) { + try { + sessions.push(await loadJsonlSessionMetadata(this.fs, file.path)); + } catch (error) { + const cause = toError(error); + if (!(cause instanceof SessionError) || cause.code !== "invalid_session") throw cause; + } + } + } + sessions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + return sessions; + } + + async delete(metadata: JsonlSessionMetadata): Promise<void> { + getFileSystemResultOrThrow( + await this.fs.remove(metadata.path, { force: true }), + `Failed to delete session ${metadata.path}`, + ); + } + + async fork( + sourceMetadata: JsonlSessionMetadata, + options: JsonlSessionCreateOptions & { entryId?: string; position?: "before" | "at"; id?: string }, + ): Promise<Session<JsonlSessionMetadata>> { + const source = await this.open(sourceMetadata); + const forkedEntries = await getEntriesToFork(source.getStorage(), options); + const id = options.id ?? createSessionId(); + const createdAt = createTimestamp(); + const sessionDir = await this.getSessionDir(options.cwd); + getFileSystemResultOrThrow( + await this.fs.createDir(sessionDir, { recursive: true }), + `Failed to create session directory ${sessionDir}`, + ); + const storage = await JsonlSessionStorage.create( + this.fs, + await this.createSessionFilePath(options.cwd, id, createdAt), + { + cwd: options.cwd, + sessionId: id, + parentSessionPath: options.parentSessionPath ?? sourceMetadata.path, + }, + ); + for (const entry of forkedEntries) { + await storage.appendEntry(entry); + } + return toSession(storage); + } + + private async listSessionDirs(): Promise<string[]> { + const sessionsRoot = await this.getSessionsRoot(); + if ( + !getFileSystemResultOrThrow( + await this.fs.exists(sessionsRoot), + `Failed to check sessions root ${sessionsRoot}`, + ) + ) { + return []; + } + const entries = getFileSystemResultOrThrow( + await this.fs.listDir(sessionsRoot), + `Failed to list sessions root ${sessionsRoot}`, + ); + return entries.filter((entry) => entry.kind === "directory").map((entry) => entry.path); + } +} diff --git a/emain/agent/harness/session/jsonl-storage.ts b/emain/agent/harness/session/jsonl-storage.ts new file mode 100644 index 000000000..56fbfc908 --- /dev/null +++ b/emain/agent/harness/session/jsonl-storage.ts @@ -0,0 +1,296 @@ +import type { FileSystem, JsonlSessionMetadata, LeafEntry, SessionStorage, SessionTreeEntry } from "../types"; +import { SessionError, toError } from "../types"; +import { getFileSystemResultOrThrow } from "./repo-utils"; +import { uuidv7 } from "./uuid"; + +type JsonlSessionStorageFileSystem = Pick<FileSystem, "readTextFile" | "readTextLines" | "writeFile" | "appendFile">; + +interface SessionHeader { + type: "session"; + version: 3; + id: string; + timestamp: string; + cwd: string; + parentSession?: string; +} + +function updateLabelCache(labelsById: Map<string, string>, entry: SessionTreeEntry): void { + if (entry.type !== "label") return; + const label = entry.label?.trim(); + if (label) { + labelsById.set(entry.targetId, label); + } else { + labelsById.delete(entry.targetId); + } +} + +function buildLabelsById(entries: SessionTreeEntry[]): Map<string, string> { + const labelsById = new Map<string, string>(); + for (const entry of entries) { + updateLabelCache(labelsById, entry); + } + return labelsById; +} + +function generateEntryId(byId: { has(id: string): boolean }): string { + for (let i = 0; i < 100; i++) { + const id = uuidv7().slice(0, 8); + if (!byId.has(id)) return id; + } + return uuidv7(); +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null; +} + +function invalidSession(filePath: string, message: string, cause?: Error): SessionError { + return new SessionError("invalid_session", `Invalid JSONL session file ${filePath}: ${message}`, cause); +} + +function invalidEntry(filePath: string, lineNumber: number, message: string, cause?: Error): SessionError { + return new SessionError( + "invalid_entry", + `Invalid JSONL session file ${filePath}: line ${lineNumber} ${message}`, + cause, + ); +} + +function parseHeaderLine(line: string, filePath: string): SessionHeader { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (error) { + throw invalidSession(filePath, "first line is not a valid session header", toError(error)); + } + if (!isRecord(parsed)) throw invalidSession(filePath, "first line is not a valid session header"); + if (parsed.type !== "session") throw invalidSession(filePath, "first line is not a valid session header"); + if (parsed.version !== 3) throw invalidSession(filePath, "unsupported session version"); + if (typeof parsed.id !== "string" || !parsed.id) throw invalidSession(filePath, "session header is missing id"); + if (typeof parsed.timestamp !== "string" || !parsed.timestamp) { + throw invalidSession(filePath, "session header is missing timestamp"); + } + if (typeof parsed.cwd !== "string" || !parsed.cwd) throw invalidSession(filePath, "session header is missing cwd"); + if (parsed.parentSession !== undefined && typeof parsed.parentSession !== "string") { + throw invalidSession(filePath, "session header parentSession must be a string"); + } + return { + type: "session", + version: 3, + id: parsed.id, + timestamp: parsed.timestamp, + cwd: parsed.cwd, + // parsed.parentSession is `unknown` for TS but already validated as + // (string | undefined) above. crest's `strict: false` doesn't narrow + // the index access, hence the cast. + parentSession: parsed.parentSession as string | undefined, + }; +} + +function parseEntryLine(line: string, filePath: string, lineNumber: number): SessionTreeEntry { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (error) { + throw invalidEntry(filePath, lineNumber, "is not valid JSON", toError(error)); + } + if (!isRecord(parsed)) throw invalidEntry(filePath, lineNumber, "is not a valid session entry"); + if (typeof parsed.type !== "string") throw invalidEntry(filePath, lineNumber, "is missing entry type"); + if (typeof parsed.id !== "string" || !parsed.id) throw invalidEntry(filePath, lineNumber, "is missing entry id"); + if (parsed.parentId !== null && typeof parsed.parentId !== "string") { + throw invalidEntry(filePath, lineNumber, "has invalid parentId"); + } + if (typeof parsed.timestamp !== "string" || !parsed.timestamp) { + throw invalidEntry(filePath, lineNumber, "is missing timestamp"); + } + if (parsed.type === "leaf" && parsed.targetId !== null && typeof parsed.targetId !== "string") { + throw invalidEntry(filePath, lineNumber, "has invalid targetId"); + } + return parsed as unknown as SessionTreeEntry; +} + +function leafIdAfterEntry(entry: SessionTreeEntry): string | null { + return entry.type === "leaf" ? entry.targetId : entry.id; +} + +function headerToSessionMetadata(header: SessionHeader, path: string): JsonlSessionMetadata { + return { + id: header.id, + createdAt: header.timestamp, + cwd: header.cwd, + path, + parentSessionPath: header.parentSession, + }; +} + +export async function loadJsonlSessionMetadata( + fs: JsonlSessionStorageFileSystem, + filePath: string, +): Promise<JsonlSessionMetadata> { + const lines = getFileSystemResultOrThrow( + await fs.readTextLines(filePath, { maxLines: 1 }), + `Failed to read session header ${filePath}`, + ); + const line = lines[0]; + if (line?.trim()) return headerToSessionMetadata(parseHeaderLine(line, filePath), filePath); + throw invalidSession(filePath, "missing session header"); +} + +async function loadJsonlStorage( + fs: JsonlSessionStorageFileSystem, + filePath: string, +): Promise<{ + header: SessionHeader; + entries: SessionTreeEntry[]; + leafId: string | null; +}> { + const content = getFileSystemResultOrThrow(await fs.readTextFile(filePath), `Failed to read session ${filePath}`); + const lines = content.split("\n").filter((line) => line.trim()); + if (lines.length === 0) { + throw invalidSession(filePath, "missing session header"); + } + + const header = parseHeaderLine(lines[0]!, filePath); + const entries: SessionTreeEntry[] = []; + let leafId: string | null = null; + for (let i = 1; i < lines.length; i++) { + const entry = parseEntryLine(lines[i]!, filePath, i + 1); + entries.push(entry); + leafId = leafIdAfterEntry(entry); + } + return { header, entries, leafId }; +} + +export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata> { + private readonly fs: JsonlSessionStorageFileSystem; + private readonly filePath: string; + private readonly metadata: JsonlSessionMetadata; + private entries: SessionTreeEntry[]; + private byId: Map<string, SessionTreeEntry>; + private labelsById: Map<string, string>; + private currentLeafId: string | null; + + private constructor( + fs: JsonlSessionStorageFileSystem, + filePath: string, + header: SessionHeader, + entries: SessionTreeEntry[], + leafId: string | null, + ) { + this.fs = fs; + this.filePath = filePath; + this.metadata = headerToSessionMetadata(header, this.filePath); + this.entries = entries; + this.byId = new Map(entries.map((entry) => [entry.id, entry])); + this.labelsById = buildLabelsById(entries); + this.currentLeafId = leafId; + } + + static async open(fs: JsonlSessionStorageFileSystem, filePath: string): Promise<JsonlSessionStorage> { + const loaded = await loadJsonlStorage(fs, filePath); + return new JsonlSessionStorage(fs, filePath, loaded.header, loaded.entries, loaded.leafId); + } + + static async create( + fs: JsonlSessionStorageFileSystem, + filePath: string, + options: { + cwd: string; + sessionId: string; + parentSessionPath?: string; + }, + ): Promise<JsonlSessionStorage> { + const header: SessionHeader = { + type: "session", + version: 3, + id: options.sessionId, + timestamp: new Date().toISOString(), + cwd: options.cwd, + parentSession: options.parentSessionPath, + }; + getFileSystemResultOrThrow( + await fs.writeFile(filePath, `${JSON.stringify(header)}\n`), + `Failed to create session ${filePath}`, + ); + return new JsonlSessionStorage(fs, filePath, header, [], null); + } + + async getMetadata(): Promise<JsonlSessionMetadata> { + return this.metadata; + } + + async getLeafId(): Promise<string | null> { + if (this.currentLeafId !== null && !this.byId.has(this.currentLeafId)) { + throw new SessionError("invalid_session", `Entry ${this.currentLeafId} not found`); + } + return this.currentLeafId; + } + + async setLeafId(leafId: string | null): Promise<void> { + if (leafId !== null && !this.byId.has(leafId)) { + throw new SessionError("not_found", `Entry ${leafId} not found`); + } + const entry: LeafEntry = { + type: "leaf", + id: generateEntryId(this.byId), + parentId: this.currentLeafId, + timestamp: new Date().toISOString(), + targetId: leafId, + }; + getFileSystemResultOrThrow( + await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`), + `Failed to append session leaf ${entry.id}`, + ); + this.entries.push(entry); + this.byId.set(entry.id, entry); + this.currentLeafId = leafId; + } + + async createEntryId(): Promise<string> { + return generateEntryId(this.byId); + } + + async appendEntry(entry: SessionTreeEntry): Promise<void> { + getFileSystemResultOrThrow( + await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`), + `Failed to append session entry ${entry.id}`, + ); + this.entries.push(entry); + this.byId.set(entry.id, entry); + updateLabelCache(this.labelsById, entry); + this.currentLeafId = leafIdAfterEntry(entry); + } + + async getEntry(id: string): Promise<SessionTreeEntry | undefined> { + return this.byId.get(id); + } + + async findEntries<TType extends SessionTreeEntry["type"]>( + type: TType, + ): Promise<Array<Extract<SessionTreeEntry, { type: TType }>>> { + return this.entries.filter((entry): entry is Extract<SessionTreeEntry, { type: TType }> => entry.type === type); + } + + async getLabel(id: string): Promise<string | undefined> { + return this.labelsById.get(id); + } + + async getPathToRoot(leafId: string | null): Promise<SessionTreeEntry[]> { + if (leafId === null) return []; + const path: SessionTreeEntry[] = []; + let current = this.byId.get(leafId); + if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`); + while (current) { + path.unshift(current); + if (!current.parentId) break; + const parent = this.byId.get(current.parentId); + if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`); + current = parent; + } + return path; + } + + async getEntries(): Promise<SessionTreeEntry[]> { + return [...this.entries]; + } +} diff --git a/emain/agent/harness/session/memory-repo.ts b/emain/agent/harness/session/memory-repo.ts new file mode 100644 index 000000000..5fcefd265 --- /dev/null +++ b/emain/agent/harness/session/memory-repo.ts @@ -0,0 +1,50 @@ +import { type Session, SessionError, type SessionMetadata, type SessionRepo } from "../types"; +import { InMemorySessionStorage } from "./memory-storage"; +import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./repo-utils"; + +export class InMemorySessionRepo implements SessionRepo<SessionMetadata, { id?: string }, void> { + private sessions = new Map<string, Session<SessionMetadata>>(); + + async create(options: { id?: string } = {}): Promise<Session<SessionMetadata>> { + const metadata: SessionMetadata = { + id: options.id ?? createSessionId(), + createdAt: createTimestamp(), + }; + const storage = new InMemorySessionStorage({ metadata }); + const session = toSession(storage); + this.sessions.set(metadata.id, session); + return session; + } + + async open(metadata: SessionMetadata): Promise<Session<SessionMetadata>> { + const session = this.sessions.get(metadata.id); + if (!session) { + throw new SessionError("not_found", `Session not found: ${metadata.id}`); + } + return session; + } + + async list(): Promise<SessionMetadata[]> { + return Promise.all([...this.sessions.values()].map((session) => session.getMetadata())); + } + + async delete(metadata: SessionMetadata): Promise<void> { + this.sessions.delete(metadata.id); + } + + async fork( + sourceMetadata: SessionMetadata, + options: { entryId?: string; position?: "before" | "at"; id?: string }, + ): Promise<Session<SessionMetadata>> { + const source = await this.open(sourceMetadata); + const forkedEntries = await getEntriesToFork(source.getStorage(), options); + const metadata: SessionMetadata = { + id: options.id ?? createSessionId(), + createdAt: createTimestamp(), + }; + const storage = new InMemorySessionStorage({ metadata, entries: forkedEntries }); + const session = toSession(storage); + this.sessions.set(metadata.id, session); + return session; + } +} diff --git a/emain/agent/harness/session/memory-storage.ts b/emain/agent/harness/session/memory-storage.ts new file mode 100644 index 000000000..f2d1da82b --- /dev/null +++ b/emain/agent/harness/session/memory-storage.ts @@ -0,0 +1,131 @@ +import { + type LeafEntry, + SessionError, + type SessionMetadata, + type SessionStorage, + type SessionTreeEntry, +} from "../types"; +import { uuidv7 } from "./uuid"; + +function updateLabelCache(labelsById: Map<string, string>, entry: SessionTreeEntry): void { + if (entry.type !== "label") return; + const label = entry.label?.trim(); + if (label) { + labelsById.set(entry.targetId, label); + } else { + labelsById.delete(entry.targetId); + } +} + +function buildLabelsById(entries: SessionTreeEntry[]): Map<string, string> { + const labelsById = new Map<string, string>(); + for (const entry of entries) { + updateLabelCache(labelsById, entry); + } + return labelsById; +} + +function generateEntryId(byId: { has(id: string): boolean }): string { + for (let i = 0; i < 100; i++) { + const id = uuidv7().slice(0, 8); + if (!byId.has(id)) return id; + } + return uuidv7(); +} + +function leafIdAfterEntry(entry: SessionTreeEntry): string | null { + return entry.type === "leaf" ? entry.targetId : entry.id; +} + +export class InMemorySessionStorage<TMetadata extends SessionMetadata = SessionMetadata> + implements SessionStorage<TMetadata> +{ + private readonly metadata: TMetadata; + private entries: SessionTreeEntry[]; + private byId: Map<string, SessionTreeEntry>; + private labelsById: Map<string, string>; + private leafId: string | null; + + constructor(options?: { entries?: SessionTreeEntry[]; metadata?: TMetadata }) { + this.entries = options?.entries ? [...options.entries] : []; + this.byId = new Map(this.entries.map((entry) => [entry.id, entry])); + this.labelsById = buildLabelsById(this.entries); + this.leafId = null; + for (const entry of this.entries) this.leafId = leafIdAfterEntry(entry); + if (this.leafId !== null && !this.byId.has(this.leafId)) { + throw new SessionError("invalid_session", `Entry ${this.leafId} not found`); + } + this.metadata = options?.metadata ?? ({ id: uuidv7(), createdAt: new Date().toISOString() } as TMetadata); + } + + async getMetadata(): Promise<TMetadata> { + return this.metadata; + } + + async getLeafId(): Promise<string | null> { + if (this.leafId !== null && !this.byId.has(this.leafId)) { + throw new SessionError("invalid_session", `Entry ${this.leafId} not found`); + } + return this.leafId; + } + + async setLeafId(leafId: string | null): Promise<void> { + if (leafId !== null && !this.byId.has(leafId)) { + throw new SessionError("not_found", `Entry ${leafId} not found`); + } + const entry: LeafEntry = { + type: "leaf", + id: generateEntryId(this.byId), + parentId: this.leafId, + timestamp: new Date().toISOString(), + targetId: leafId, + }; + this.entries.push(entry); + this.byId.set(entry.id, entry); + this.leafId = leafId; + } + + async createEntryId(): Promise<string> { + return generateEntryId(this.byId); + } + + async appendEntry(entry: SessionTreeEntry): Promise<void> { + this.entries.push(entry); + this.byId.set(entry.id, entry); + updateLabelCache(this.labelsById, entry); + this.leafId = leafIdAfterEntry(entry); + } + + async getEntry(id: string): Promise<SessionTreeEntry | undefined> { + return this.byId.get(id); + } + + async findEntries<TType extends SessionTreeEntry["type"]>( + type: TType, + ): Promise<Array<Extract<SessionTreeEntry, { type: TType }>>> { + return this.entries.filter((entry): entry is Extract<SessionTreeEntry, { type: TType }> => entry.type === type); + } + + async getLabel(id: string): Promise<string | undefined> { + return this.labelsById.get(id); + } + + async getPathToRoot(leafId: string | null): Promise<SessionTreeEntry[]> { + if (leafId === null) return []; + const path: SessionTreeEntry[] = []; + let current = this.byId.get(leafId); + if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`); + while (current) { + path.unshift(current); + if (!current.parentId) break; + const parent = this.byId.get(current.parentId); + if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`); + current = parent; + } + return path; + } + + async getEntries(): Promise<SessionTreeEntry[]> { + return [...this.entries]; + } +} diff --git a/emain/agent/harness/session/repo-utils.ts b/emain/agent/harness/session/repo-utils.ts new file mode 100644 index 000000000..aa10cc89d --- /dev/null +++ b/emain/agent/harness/session/repo-utils.ts @@ -0,0 +1,51 @@ +import { + type FileError, + type Result, + SessionError, + type SessionMetadata, + type SessionStorage, + type SessionTreeEntry, +} from "../types"; +import { Session } from "./session"; +import { uuidv7 } from "./uuid"; + +export function createSessionId(): string { + return uuidv7(); +} + +export function createTimestamp(): string { + return new Date().toISOString(); +} + +export function toSession<TMetadata extends SessionMetadata>(storage: SessionStorage<TMetadata>): Session<TMetadata> { + return new Session(storage); +} + +export function getFileSystemResultOrThrow<TValue>(result: Result<TValue, FileError>, message: string): TValue { + if (!result.ok) { + const code = result.error.code === "not_found" ? "not_found" : "storage"; + throw new SessionError(code, `${message}: ${result.error.message}`, result.error); + } + return result.value; +} + +export async function getEntriesToFork( + storage: SessionStorage, + options: { entryId?: string; position?: "before" | "at" }, +): Promise<SessionTreeEntry[]> { + if (!options.entryId) return storage.getEntries(); + const target = await storage.getEntry(options.entryId); + if (!target) { + throw new SessionError("invalid_fork_target", `Entry ${options.entryId} not found`); + } + let effectiveLeafId: string | null; + if ((options.position ?? "before") === "at") { + effectiveLeafId = target.id; + } else { + if (target.type !== "message" || target.message.role !== "user") { + throw new SessionError("invalid_fork_target", `Entry ${options.entryId} is not a user message`); + } + effectiveLeafId = target.parentId; + } + return storage.getPathToRoot(effectiveLeafId); +} diff --git a/emain/agent/harness/session/session.ts b/emain/agent/harness/session/session.ts new file mode 100644 index 000000000..4b2761861 --- /dev/null +++ b/emain/agent/harness/session/session.ts @@ -0,0 +1,252 @@ +import type { ImageContent, TextContent } from "../../../ai"; +import type { AgentMessage } from "../../types"; +import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage } from "../messages"; +import type { + BranchSummaryEntry, + CompactionEntry, + CustomEntry, + CustomMessageEntry, + LabelEntry, + MessageEntry, + ModelChangeEntry, + SessionContext, + SessionInfoEntry, + SessionMetadata, + SessionStorage, + SessionTreeEntry, + ThinkingLevelChangeEntry, +} from "../types"; +import { SessionError } from "../types"; + +export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionContext { + let thinkingLevel = "off"; + let model: { provider: string; modelId: string } | null = null; + let compaction: CompactionEntry | null = null; + + for (const entry of pathEntries) { + if (entry.type === "thinking_level_change") { + thinkingLevel = entry.thinkingLevel; + } else if (entry.type === "model_change") { + model = { provider: entry.provider, modelId: entry.modelId }; + } else if (entry.type === "message" && entry.message.role === "assistant") { + model = { provider: entry.message.provider, modelId: entry.message.model }; + } else if (entry.type === "compaction") { + compaction = entry; + } + } + + const messages: AgentMessage[] = []; + const appendMessage = (entry: SessionTreeEntry) => { + if (entry.type === "message") { + messages.push(entry.message as AgentMessage); + } else if (entry.type === "custom_message") { + messages.push( + createCustomMessage( + entry.customType, + entry.content as string | (TextContent | ImageContent)[], + entry.display, + entry.details, + entry.timestamp, + ), + ); + } else if (entry.type === "branch_summary" && entry.summary) { + messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)); + } + }; + + if (compaction) { + messages.push(createCompactionSummaryMessage(compaction.summary, compaction.tokensBefore, compaction.timestamp)); + const compactionIdx = pathEntries.findIndex((e) => e.type === "compaction" && e.id === compaction.id); + let foundFirstKept = false; + for (let i = 0; i < compactionIdx; i++) { + const entry = pathEntries[i]!; + if (entry.id === compaction.firstKeptEntryId) foundFirstKept = true; + if (foundFirstKept) appendMessage(entry); + } + for (let i = compactionIdx + 1; i < pathEntries.length; i++) { + appendMessage(pathEntries[i]!); + } + } else { + for (const entry of pathEntries) { + appendMessage(entry); + } + } + + return { messages, thinkingLevel, model }; +} + +export class Session<TMetadata extends SessionMetadata = SessionMetadata> { + private storage: SessionStorage<TMetadata>; + + constructor(storage: SessionStorage<TMetadata>) { + this.storage = storage; + } + + getMetadata(): Promise<TMetadata> { + return this.storage.getMetadata(); + } + + getStorage(): SessionStorage<TMetadata> { + return this.storage; + } + + getLeafId(): Promise<string | null> { + return this.storage.getLeafId(); + } + + getEntry(id: string): Promise<SessionTreeEntry | undefined> { + return this.storage.getEntry(id); + } + + getEntries(): Promise<SessionTreeEntry[]> { + return this.storage.getEntries(); + } + + async getBranch(fromId?: string): Promise<SessionTreeEntry[]> { + const leafId = fromId ?? (await this.storage.getLeafId()); + return this.storage.getPathToRoot(leafId); + } + + async buildContext(): Promise<SessionContext> { + return buildSessionContext(await this.getBranch()); + } + + getLabel(id: string): Promise<string | undefined> { + return this.storage.getLabel(id); + } + + async getSessionName(): Promise<string | undefined> { + const entries = await this.storage.findEntries("session_info"); + return entries[entries.length - 1]?.name?.trim() || undefined; + } + + private async appendTypedEntry<TEntry extends SessionTreeEntry>(entry: TEntry): Promise<string> { + await this.storage.appendEntry(entry); + return entry.id; + } + + async appendMessage(message: AgentMessage): Promise<string> { + return this.appendTypedEntry({ + type: "message", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + message, + } satisfies MessageEntry); + } + + async appendThinkingLevelChange(thinkingLevel: string): Promise<string> { + return this.appendTypedEntry({ + type: "thinking_level_change", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + thinkingLevel, + } satisfies ThinkingLevelChangeEntry); + } + + async appendModelChange(provider: string, modelId: string): Promise<string> { + return this.appendTypedEntry({ + type: "model_change", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + provider, + modelId, + } satisfies ModelChangeEntry); + } + + async appendCompaction<T = unknown>( + summary: string, + firstKeptEntryId: string, + tokensBefore: number, + details?: T, + fromHook?: boolean, + ): Promise<string> { + return this.appendTypedEntry({ + type: "compaction", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + summary, + firstKeptEntryId, + tokensBefore, + details, + fromHook, + } satisfies CompactionEntry<T>); + } + + async appendCustomEntry(customType: string, data?: unknown): Promise<string> { + return this.appendTypedEntry({ + type: "custom", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + customType, + data, + } satisfies CustomEntry); + } + + async appendCustomMessageEntry<T = unknown>( + customType: string, + content: string | (TextContent | ImageContent)[], + display: boolean, + details?: T, + ): Promise<string> { + return this.appendTypedEntry({ + type: "custom_message", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + customType, + content, + display, + details, + } satisfies CustomMessageEntry<T>); + } + + async appendLabel(targetId: string, label: string | undefined): Promise<string> { + if (!(await this.storage.getEntry(targetId))) { + throw new SessionError("not_found", `Entry ${targetId} not found`); + } + return this.appendTypedEntry({ + type: "label", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + targetId, + label, + } satisfies LabelEntry); + } + + async appendSessionName(name: string): Promise<string> { + return this.appendTypedEntry({ + type: "session_info", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + name: name.trim(), + } satisfies SessionInfoEntry); + } + + async moveTo( + entryId: string | null, + summary?: { summary: string; details?: unknown; fromHook?: boolean }, + ): Promise<string | undefined> { + if (entryId !== null && !(await this.storage.getEntry(entryId))) { + throw new SessionError("not_found", `Entry ${entryId} not found`); + } + await this.storage.setLeafId(entryId); + if (!summary) return undefined; + return this.appendTypedEntry({ + type: "branch_summary", + id: await this.storage.createEntryId(), + parentId: entryId, + timestamp: new Date().toISOString(), + fromId: entryId ?? "root", + summary: summary.summary, + details: summary.details, + fromHook: summary.fromHook, + } satisfies BranchSummaryEntry); + } +} diff --git a/emain/agent/harness/session/uuid.ts b/emain/agent/harness/session/uuid.ts new file mode 100644 index 000000000..0c4cef8e0 --- /dev/null +++ b/emain/agent/harness/session/uuid.ts @@ -0,0 +1,54 @@ +let lastTimestamp = -Infinity; +let sequence = 0; + +function fillRandomBytes(bytes: Uint8Array): void { + const crypto = globalThis.crypto; + if (crypto?.getRandomValues) { + crypto.getRandomValues(bytes); + return; + } + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Math.floor(Math.random() * 256); + } +} + +export function uuidv7(): string { + const random = new Uint8Array(16); + fillRandomBytes(random); + const timestamp = Date.now(); + + if (timestamp > lastTimestamp) { + sequence = random[6] * 0x1000000 + random[7] * 0x10000 + random[8] * 0x100 + random[9]; + lastTimestamp = timestamp; + } else { + sequence = (sequence + 1) >>> 0; + if (sequence === 0) { + lastTimestamp++; + } + } + + const bytes = new Uint8Array(16); + bytes[0] = (lastTimestamp / 0x10000000000) & 0xff; + bytes[1] = (lastTimestamp / 0x100000000) & 0xff; + bytes[2] = (lastTimestamp / 0x1000000) & 0xff; + bytes[3] = (lastTimestamp / 0x10000) & 0xff; + bytes[4] = (lastTimestamp / 0x100) & 0xff; + bytes[5] = lastTimestamp & 0xff; + bytes[6] = 0x70 | ((sequence >>> 28) & 0x0f); + bytes[7] = (sequence >>> 20) & 0xff; + bytes[8] = 0x80 | ((sequence >>> 14) & 0x3f); + bytes[9] = (sequence >>> 6) & 0xff; + bytes[10] = ((sequence & 0x3f) << 2) | (random[10] & 0x03); + bytes[11] = random[11]; + bytes[12] = random[12]; + bytes[13] = random[13]; + bytes[14] = random[14]; + bytes[15] = random[15]; + + return formatUuid(bytes); +} + +function formatUuid(bytes: Uint8Array): string { + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")); + return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`; +} diff --git a/emain/agent/harness/skills.ts b/emain/agent/harness/skills.ts new file mode 100644 index 000000000..4f9f20b03 --- /dev/null +++ b/emain/agent/harness/skills.ts @@ -0,0 +1,375 @@ +import ignore from "ignore"; +import { parse } from "yaml"; +import { type ExecutionEnv, type FileInfo, type Result, type Skill, toError } from "./types"; + +const MAX_NAME_LENGTH = 64; +const MAX_DESCRIPTION_LENGTH = 1024; +const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"]; + +type IgnoreMatcher = ReturnType<typeof ignore>; + +export type SkillDiagnosticCode = + | "file_info_failed" + | "list_failed" + | "read_failed" + | "parse_failed" + | "invalid_metadata"; + +/** Warning produced while loading skills. */ +export interface SkillDiagnostic { + /** Diagnostic severity. Currently only warnings are emitted. */ + type: "warning"; + /** Stable diagnostic code. */ + code: SkillDiagnosticCode; + /** Human-readable diagnostic message. */ + message: string; + /** Path associated with the diagnostic. */ + path: string; +} + +interface SkillFrontmatter { + name?: string; + description?: string; + "disable-model-invocation"?: boolean; + [key: string]: unknown; +} + +/** Format a skill invocation prompt, optionally appending additional user instructions. */ +export function formatSkillInvocation(skill: Skill, additionalInstructions?: string): string { + const skillBlock = `<skill name="${skill.name}" location="${skill.filePath}">\nReferences are relative to ${dirnameEnvPath(skill.filePath)}.\n\n${skill.content}\n</skill>`; + return additionalInstructions ? `${skillBlock}\n\n${additionalInstructions}` : skillBlock; +} + +/** + * Load skills from one or more directories. + * + * Traverses directories recursively, loads `SKILL.md` files, loads direct root `.md` files as skills, honors ignore files, + * and returns diagnostics for invalid skill files. Missing input directories are skipped. + */ +export async function loadSkills( + env: ExecutionEnv, + dirs: string | string[], +): Promise<{ skills: Skill[]; diagnostics: SkillDiagnostic[] }> { + const skills: Skill[] = []; + const diagnostics: SkillDiagnostic[] = []; + for (const dir of Array.isArray(dirs) ? dirs : [dirs]) { + const rootInfoResult = await env.fileInfo(dir); + if (!rootInfoResult.ok) { + if (rootInfoResult.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: rootInfoResult.error.message, + path: dir, + }); + } + continue; + } + const rootInfo = rootInfoResult.value; + if ((await resolveKind(env, rootInfo, diagnostics)) !== "directory") continue; + const result = await loadSkillsFromDirInternal(env, rootInfo.path, true, ignore(), rootInfo.path); + skills.push(...result.skills); + diagnostics.push(...result.diagnostics); + } + return { skills, diagnostics }; +} + +/** + * Load skills from source-tagged directories. + * + * Source values are preserved exactly and attached to every loaded skill and diagnostic. The agent package does not + * interpret source values; applications define their own provenance shape. + */ +export async function loadSourcedSkills<TSource, TSkill extends Skill = Skill>( + env: ExecutionEnv, + inputs: Array<{ path: string; source: TSource }>, + mapSkill?: (skill: Skill, source: TSource) => TSkill, +): Promise<{ + skills: Array<{ skill: TSkill; source: TSource }>; + diagnostics: Array<SkillDiagnostic & { source: TSource }>; +}> { + const skills: Array<{ skill: TSkill; source: TSource }> = []; + const diagnostics: Array<SkillDiagnostic & { source: TSource }> = []; + for (const input of inputs) { + const result = await loadSkills(env, input.path); + for (const skill of result.skills) { + skills.push({ skill: mapSkill ? mapSkill(skill, input.source) : (skill as TSkill), source: input.source }); + } + for (const diagnostic of result.diagnostics) diagnostics.push({ ...diagnostic, source: input.source }); + } + return { skills, diagnostics }; +} + +async function loadSkillsFromDirInternal( + env: ExecutionEnv, + dir: string, + includeRootFiles: boolean, + ignoreMatcher: IgnoreMatcher, + rootDir: string, +): Promise<{ skills: Skill[]; diagnostics: SkillDiagnostic[] }> { + const skills: Skill[] = []; + const diagnostics: SkillDiagnostic[] = []; + + const dirInfoResult = await env.fileInfo(dir); + if (!dirInfoResult.ok) { + if (dirInfoResult.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: dirInfoResult.error.message, + path: dir, + }); + } + return { skills, diagnostics }; + } + const dirInfo = dirInfoResult.value; + if ((await resolveKind(env, dirInfo, diagnostics)) !== "directory") return { skills, diagnostics }; + + await addIgnoreRules(env, ignoreMatcher, dir, rootDir, diagnostics); + + const entriesResult = await env.listDir(dir); + if (!entriesResult.ok) { + diagnostics.push({ type: "warning", code: "list_failed", message: entriesResult.error.message, path: dir }); + return { skills, diagnostics }; + } + const entries = entriesResult.value; + + for (const entry of entries) { + if (entry.name !== "SKILL.md") continue; + const fullPath = entry.path; + const kind = await resolveKind(env, entry, diagnostics); + if (kind !== "file") continue; + const relPath = relativeEnvPath(rootDir, fullPath); + if (ignoreMatcher.ignores(relPath)) continue; + + const result = await loadSkillFromFile(env, fullPath); + if (result.skill) skills.push(result.skill); + diagnostics.push(...result.diagnostics); + return { skills, diagnostics }; + } + + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (entry.name.startsWith(".") || entry.name === "node_modules") continue; + const fullPath = entry.path; + const kind = await resolveKind(env, entry, diagnostics); + if (!kind) continue; + + const relPath = relativeEnvPath(rootDir, fullPath); + const ignorePath = kind === "directory" ? `${relPath}/` : relPath; + if (ignoreMatcher.ignores(ignorePath)) continue; + + if (kind === "directory") { + const result = await loadSkillsFromDirInternal(env, fullPath, false, ignoreMatcher, rootDir); + skills.push(...result.skills); + diagnostics.push(...result.diagnostics); + continue; + } + + if (kind !== "file" || !includeRootFiles || !entry.name.endsWith(".md")) continue; + const result = await loadSkillFromFile(env, fullPath); + if (result.skill) skills.push(result.skill); + diagnostics.push(...result.diagnostics); + } + + return { skills, diagnostics }; +} + +async function addIgnoreRules( + env: ExecutionEnv, + ig: IgnoreMatcher, + dir: string, + rootDir: string, + diagnostics: SkillDiagnostic[], +): Promise<void> { + const relativeDir = relativeEnvPath(rootDir, dir); + const prefix = relativeDir ? `${relativeDir}/` : ""; + + for (const filename of IGNORE_FILE_NAMES) { + const ignorePath = joinEnvPath(dir, filename); + const info = await env.fileInfo(ignorePath); + if (!info.ok) { + if (info.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: info.error.message, + path: ignorePath, + }); + } + continue; + } + if (info.value.kind !== "file") continue; + const content = await env.readTextFile(ignorePath); + if (!content.ok) { + diagnostics.push({ type: "warning", code: "read_failed", message: content.error.message, path: ignorePath }); + continue; + } + const patterns = content.value + .split(/\r?\n/) + .map((line) => prefixIgnorePattern(line, prefix)) + .filter((line): line is string => Boolean(line)); + if (patterns.length > 0) ig.add(patterns); + } +} + +function prefixIgnorePattern(line: string, prefix: string): string | null { + const trimmed = line.trim(); + if (!trimmed) return null; + if (trimmed.startsWith("#") && !trimmed.startsWith("\\#")) return null; + + let pattern = line; + let negated = false; + if (pattern.startsWith("!")) { + negated = true; + pattern = pattern.slice(1); + } else if (pattern.startsWith("\\!")) { + pattern = pattern.slice(1); + } + if (pattern.startsWith("/")) pattern = pattern.slice(1); + const prefixed = prefix ? `${prefix}${pattern}` : pattern; + return negated ? `!${prefixed}` : prefixed; +} + +async function loadSkillFromFile( + env: ExecutionEnv, + filePath: string, +): Promise<{ skill: Skill | null; diagnostics: SkillDiagnostic[] }> { + const diagnostics: SkillDiagnostic[] = []; + const rawContent = await env.readTextFile(filePath); + if (!rawContent.ok) { + diagnostics.push({ type: "warning", code: "read_failed", message: rawContent.error.message, path: filePath }); + return { skill: null, diagnostics }; + } + + const parsed = parseFrontmatter<SkillFrontmatter>(rawContent.value); + if (!parsed.ok) { + diagnostics.push({ type: "warning", code: "parse_failed", message: parsed.error.message, path: filePath }); + return { skill: null, diagnostics }; + } + + const { frontmatter, body } = parsed.value; + const skillDir = dirnameEnvPath(filePath); + const parentDirName = basenameEnvPath(skillDir); + const description = typeof frontmatter.description === "string" ? frontmatter.description : undefined; + + for (const error of validateDescription(description)) { + diagnostics.push({ type: "warning", code: "invalid_metadata", message: error, path: filePath }); + } + + const frontmatterName = typeof frontmatter.name === "string" ? frontmatter.name : undefined; + const name = frontmatterName || parentDirName; + for (const error of validateName(name, parentDirName)) { + diagnostics.push({ type: "warning", code: "invalid_metadata", message: error, path: filePath }); + } + + if (!description || description.trim() === "") { + return { skill: null, diagnostics }; + } + + return { + skill: { + name, + description, + content: body, + filePath, + disableModelInvocation: frontmatter["disable-model-invocation"] === true, + }, + diagnostics, + }; +} + +function validateName(name: string, parentDirName: string): string[] { + const errors: string[] = []; + if (name !== parentDirName) errors.push(`name "${name}" does not match parent directory "${parentDirName}"`); + if (name.length > MAX_NAME_LENGTH) errors.push(`name exceeds ${MAX_NAME_LENGTH} characters (${name.length})`); + if (!/^[a-z0-9-]+$/.test(name)) { + errors.push("name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)"); + } + if (name.startsWith("-") || name.endsWith("-")) errors.push("name must not start or end with a hyphen"); + if (name.includes("--")) errors.push("name must not contain consecutive hyphens"); + return errors; +} + +function validateDescription(description: string | undefined): string[] { + const errors: string[] = []; + if (!description || description.trim() === "") { + errors.push("description is required"); + } else if (description.length > MAX_DESCRIPTION_LENGTH) { + errors.push(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`); + } + return errors; +} + +function parseFrontmatter<T extends Record<string, unknown>>( + content: string, +): Result<{ frontmatter: T; body: string }, Error> { + try { + const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (!normalized.startsWith("---")) return { ok: true, value: { frontmatter: {} as T, body: normalized } }; + const endIndex = normalized.indexOf("\n---", 3); + if (endIndex === -1) return { ok: true, value: { frontmatter: {} as T, body: normalized } }; + const yamlString = normalized.slice(4, endIndex); + const body = normalized.slice(endIndex + 4).trim(); + return { ok: true, value: { frontmatter: (parse(yamlString) ?? {}) as T, body } }; + } catch (error) { + return { ok: false, error: toError(error) }; + } +} + +async function resolveKind( + env: ExecutionEnv, + info: FileInfo, + diagnostics: SkillDiagnostic[], +): Promise<"file" | "directory" | undefined> { + if (info.kind === "file" || info.kind === "directory") return info.kind; + const canonicalPath = await env.canonicalPath(info.path); + if (!canonicalPath.ok) { + if (canonicalPath.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: canonicalPath.error.message, + path: info.path, + }); + } + return undefined; + } + const target = await env.fileInfo(canonicalPath.value); + if (!target.ok) { + if (target.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: target.error.message, + path: info.path, + }); + } + return undefined; + } + return target.value.kind === "file" || target.value.kind === "directory" ? target.value.kind : undefined; +} + +function joinEnvPath(base: string, child: string): string { + return `${base.replace(/\/+$/, "")}/${child.replace(/^\/+/, "")}`; +} + +function dirnameEnvPath(path: string): string { + const normalized = path.replace(/\/+$/, ""); + const slashIndex = normalized.lastIndexOf("/"); + return slashIndex <= 0 ? "/" : normalized.slice(0, slashIndex); +} + +function basenameEnvPath(path: string): string { + const normalized = path.replace(/\/+$/, ""); + const slashIndex = normalized.lastIndexOf("/"); + return slashIndex === -1 ? normalized : normalized.slice(slashIndex + 1); +} + +function relativeEnvPath(root: string, path: string): string { + const normalizedRoot = root.replace(/\/+$/, ""); + const normalizedPath = path.replace(/\/+$/, ""); + if (normalizedPath === normalizedRoot) return ""; + return normalizedPath.startsWith(`${normalizedRoot}/`) + ? normalizedPath.slice(normalizedRoot.length + 1) + : normalizedPath.replace(/^\/+/, ""); +} diff --git a/emain/agent/harness/system-prompt.ts b/emain/agent/harness/system-prompt.ts new file mode 100644 index 000000000..4b8f363a6 --- /dev/null +++ b/emain/agent/harness/system-prompt.ts @@ -0,0 +1,34 @@ +import type { Skill } from "./types"; + +export function formatSkillsForSystemPrompt(skills: Skill[]): string { + const visibleSkills = skills.filter((skill) => !skill.disableModelInvocation); + if (visibleSkills.length === 0) return ""; + + const lines = [ + "The following skills provide specialized instructions for specific tasks.", + "Read the full skill file when the task matches its description.", + "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.", + "", + "<available_skills>", + ]; + + for (const skill of visibleSkills) { + lines.push(" <skill>"); + lines.push(` <name>${escapeXml(skill.name)}</name>`); + lines.push(` <description>${escapeXml(skill.description)}</description>`); + lines.push(` <location>${escapeXml(skill.filePath)}</location>`); + lines.push(" </skill>"); + } + + lines.push("</available_skills>"); + return lines.join("\n"); +} + +function escapeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/emain/agent/harness/types.ts b/emain/agent/harness/types.ts new file mode 100644 index 000000000..2e717a7be --- /dev/null +++ b/emain/agent/harness/types.ts @@ -0,0 +1,827 @@ +import type { ImageContent, Model, SimpleStreamOptions, TextContent, Transport } from "../../ai"; +import type { AgentEvent, AgentMessage, AgentTool, QueueMode, ThinkingLevel } from "../index"; +import type { Session } from "./session/session"; + +/** Result of a fallible operation. Expected failures are returned as `ok: false` instead of thrown. + * + * Crest-local change vs upstream pi: we flatten the discriminated union to a single + * shape with optional `value` and `error` fields. The original + * `{ ok: true; value } | { ok: false; error }` + * relies on TS narrowing on `.ok`, which is unreliable under crest's + * `strict: false` tsconfig — call sites that do + * `if (r.ok) ... else r.error` + * fail to compile because TS can't drop the `{ ok: true }` branch in the else. + * The flat shape costs us one runtime invariant (`value` / `error` are mutually + * exclusive at construction time via ok() / err() helpers below) but eliminates + * dozens of false-positive narrowing errors across the harness. + */ +export type Result<TValue, TError> = { ok: boolean; value?: TValue; error?: TError }; + +/** Create a successful {@link Result}. */ +export function ok<TValue, TError>(value: TValue): Result<TValue, TError> { + return { ok: true, value }; +} + +/** Create a failed {@link Result}. */ +export function err<TValue, TError>(error: TError): Result<TValue, TError> { + return { ok: false, error }; +} + +/** Return the success value or throw the failure error. Intended for tests and explicit adapter boundaries. */ +export function getOrThrow<TValue, TError>(result: Result<TValue, TError>): TValue { + if (!result.ok) throw result.error; + return result.value; +} + +/** Return the success value or `undefined`. Only object values are allowed to avoid truthiness bugs with primitives. */ +export function getOrUndefined<TValue extends object, TError>(result: Result<TValue, TError>): TValue | undefined { + return result.ok ? result.value : undefined; +} + +/** Normalize unknown thrown values into Error instances before using them as typed error causes. */ +export function toError(error: unknown): Error { + if (error instanceof Error) return error; + if (typeof error === "string") return new Error(error); + try { + return new Error(JSON.stringify(error)); + } catch { + return new Error(String(error)); + } +} + +/** + * Skill loaded from a `SKILL.md` file or provided by an application. + * + * `name`, `description`, and `filePath` are inserted into the system prompt in an XML-formatted block as suggested by agentskills.io. + * Use {@link formatSkillsForSystemPrompt} to generate the spec-compatible system prompt block. + */ +export interface Skill { + /** Stable skill name used for lookup and model-visible listings. */ + name: string; + /** Short model-visible description of when to use the skill. */ + description: string; + /** Full skill instructions. */ + content: string; + /** Absolute path to the skill file. Used for model-visible location and resolving relative references. */ + filePath: string; + /** Exclude this skill from model-visible skill lists while still allowing explicit application invocation. */ + disableModelInvocation?: boolean; +} + +/** Prompt template that can be formatted into a prompt for explicit invocation. */ +export interface PromptTemplate { + /** Stable template name used for lookup or application command routing. */ + name: string; + /** Optional description for command lists or autocomplete. */ + description?: string; + /** Template content. Argument placeholders are formatted by `formatPromptTemplateInvocation`. */ + content: string; +} + +/** Resources made available to explicit invocation methods and system-prompt callbacks. */ +export interface AgentHarnessResources< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, +> { + /** Prompt templates available for explicit invocation. */ + promptTemplates?: TPromptTemplate[]; + /** Skills available to the model and explicit skill invocation. */ + skills?: TSkill[]; +} + +/** Curated provider request options owned by the harness and snapshotted per turn. */ +export interface AgentHarnessStreamOptions { + /** Preferred transport forwarded to the stream function. */ + transport?: Transport; + /** Provider request timeout in milliseconds. */ + timeoutMs?: number; + /** Maximum provider retry attempts. */ + maxRetries?: number; + /** Optional cap for provider-requested retry delays. */ + maxRetryDelayMs?: number; + /** Additional request headers merged with auth and lifecycle headers. */ + headers?: Record<string, string>; + /** Provider metadata forwarded with requests. */ + metadata?: SimpleStreamOptions["metadata"]; + /** Provider cache retention hint. */ + cacheRetention?: SimpleStreamOptions["cacheRetention"]; +} + +/** Per-request stream option patch returned by provider hooks. */ +export interface AgentHarnessStreamOptionsPatch + extends Omit<Partial<AgentHarnessStreamOptions>, "headers" | "metadata"> { + /** Header patch. `undefined` values delete keys; explicit `headers: undefined` clears all headers. */ + headers?: Record<string, string | undefined>; + /** Metadata patch. `undefined` values delete keys; explicit `metadata: undefined` clears all metadata. */ + metadata?: Record<string, unknown | undefined>; +} + +/** Kind of filesystem object as addressed by a {@link FileSystem}. Symlinks are not followed automatically. */ +export type FileKind = "file" | "directory" | "symlink"; + +/** Stable, backend-independent file error codes returned by {@link FileSystem} file operations. */ +export type FileErrorCode = + | "aborted" + | "not_found" + | "permission_denied" + | "not_directory" + | "is_directory" + | "invalid" + | "not_supported" + | "unknown"; + +/** Error returned by {@link FileSystem} file operations. */ +export class FileError extends Error { + /** Backend-independent error code. */ + public code: FileErrorCode; + /** Absolute addressed path associated with the failure, when available. */ + public path?: string; + + constructor(code: FileErrorCode, message: string, path?: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "FileError"; + this.code = code; + this.path = path; + } +} + +/** Stable, backend-independent execution error codes returned by {@link ExecutionEnv.exec}. */ +export type ExecutionErrorCode = + | "aborted" + | "timeout" + | "shell_unavailable" + | "spawn_error" + | "callback_error" + | "unknown"; + +/** Error returned by {@link ExecutionEnv.exec}. */ +export class ExecutionError extends Error { + /** Backend-independent error code. */ + public code: ExecutionErrorCode; + + constructor(code: ExecutionErrorCode, message: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "ExecutionError"; + this.code = code; + } +} + +/** Stable compaction error codes returned by compaction helpers. */ +export type CompactionErrorCode = "aborted" | "summarization_failed" | "invalid_session" | "unknown"; + +/** Error returned by compaction helpers. */ +export class CompactionError extends Error { + /** Backend-independent error code. */ + public code: CompactionErrorCode; + + constructor(code: CompactionErrorCode, message: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "CompactionError"; + this.code = code; + } +} + +/** Stable branch-summary error codes returned by branch summarization helpers. */ +export type BranchSummaryErrorCode = "aborted" | "summarization_failed" | "invalid_session"; + +/** Error returned by branch summarization helpers. */ +export class BranchSummaryError extends Error { + /** Backend-independent error code. */ + public code: BranchSummaryErrorCode; + + constructor(code: BranchSummaryErrorCode, message: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "BranchSummaryError"; + this.code = code; + } +} + +export type SessionErrorCode = + | "not_found" + | "invalid_session" + | "invalid_entry" + | "invalid_fork_target" + | "storage" + | "unknown"; + +/** Error thrown by session storage, repositories, and session tree operations. */ +export class SessionError extends Error { + /** Session subsystem error code. */ + public code: SessionErrorCode; + + constructor(code: SessionErrorCode, message: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "SessionError"; + this.code = code; + } +} + +export type AgentHarnessErrorCode = + | "busy" + | "invalid_state" + | "invalid_argument" + | "session" + | "hook" + | "auth" + | "compaction" + | "branch_summary" + | "unknown"; + +/** Public AgentHarness failure with a stable top-level classification. */ +export class AgentHarnessError extends Error { + public code: AgentHarnessErrorCode; + + constructor(code: AgentHarnessErrorCode, message: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "AgentHarnessError"; + this.code = code; + } +} + +/** Metadata for one filesystem object in a {@link FileSystem}. */ +export interface FileInfo { + /** Basename of {@link path}. */ + name: string; + /** Absolute, syntactically normalized addressed path in the execution environment. Symlinks are not followed. */ + path: string; + /** Object kind. Symlink targets are not followed; use {@link FileSystem.canonicalPath} explicitly. */ + kind: FileKind; + /** Size in bytes for the addressed filesystem object. */ + size: number; + /** Modification time as milliseconds since Unix epoch. */ + mtimeMs: number; +} + +/** Options for {@link Shell.exec}. */ +export interface ExecutionEnvExecOptions { + /** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. Defaults to {@link ExecutionEnv.cwd}. */ + cwd?: string; + /** Additional environment variables for the command. Values override the environment defaults. Defaults to no overrides. */ + env?: Record<string, string>; + /** Timeout in seconds. Implementations should return a timeout error when the command exceeds this duration. Defaults to no timeout. */ + timeout?: number; + /** Abort signal used to terminate the command. Defaults to no abort signal. */ + abortSignal?: AbortSignal; + /** Called with stdout chunks as they are produced. */ + onStdout?: (chunk: string) => void; + /** Called with stderr chunks as they are produced. */ + onStderr?: (chunk: string) => void; +} + +/** + * Filesystem capability used by the harness. + * + * Paths passed to methods may be absolute or relative to {@link cwd}. Paths returned by file operations are addressed paths + * in the filesystem namespace, but are not canonicalized through symlinks unless returned by {@link canonicalPath}. + * + * Operation methods must never throw or reject. All filesystem failures, including unexpected backend failures, must be + * encoded in the returned {@link Result}. Implementations must preserve this invariant. + */ +export interface FileSystem { + /** Current working directory for relative paths. */ + cwd: string; + + /** Return an absolute addressed path without requiring it to exist and without resolving symlinks. */ + absolutePath(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>; + /** Join path segments in the filesystem namespace without requiring the result to exist. */ + joinPath(parts: string[], abortSignal?: AbortSignal): Promise<Result<string, FileError>>; + /** Read a UTF-8 text file. */ + readTextFile(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>; + /** Read UTF-8 text lines. Implementations should stop once `maxLines` lines have been read. */ + readTextLines( + path: string, + options?: { maxLines?: number; abortSignal?: AbortSignal }, + ): Promise<Result<string[], FileError>>; + /** Read a binary file. */ + readBinaryFile(path: string, abortSignal?: AbortSignal): Promise<Result<Uint8Array, FileError>>; + /** Create or overwrite a file, creating parent directories when supported. */ + writeFile(path: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise<Result<void, FileError>>; + /** Create or append to a file, creating parent directories when supported. */ + appendFile(path: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise<Result<void, FileError>>; + /** Return metadata for the addressed path without following symlinks. */ + fileInfo(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo, FileError>>; + /** List direct children of a directory without following symlinks. */ + listDir(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>>; + /** Return the canonical path for an existing path, resolving symlinks where supported. */ + canonicalPath(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>; + /** Return false for missing paths. Other errors, such as permission failures, return a {@link FileError}. */ + exists(path: string, abortSignal?: AbortSignal): Promise<Result<boolean, FileError>>; + /** Create a directory. Defaults: `recursive: true`, no abort signal. */ + createDir( + path: string, + options?: { recursive?: boolean; abortSignal?: AbortSignal }, + ): Promise<Result<void, FileError>>; + /** Remove a file or directory. Defaults: `recursive: false`, `force: false`, no abort signal. */ + remove( + path: string, + options?: { recursive?: boolean; force?: boolean; abortSignal?: AbortSignal }, + ): Promise<Result<void, FileError>>; + /** Create a temporary directory and return its absolute path. Defaults: `prefix: "tmp-"`, no abort signal. */ + createTempDir(prefix?: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>; + /** Create a temporary file and return its absolute path. Defaults: `prefix: ""`, `suffix: ""`, no abort signal. */ + createTempFile(options?: { + prefix?: string; + suffix?: string; + abortSignal?: AbortSignal; + }): Promise<Result<string, FileError>>; + + /** Release filesystem resources. Must be best-effort and must not throw or reject. */ + cleanup(): Promise<void>; +} + +/** Shell execution capability used by the harness. */ +export interface Shell { + /** Execute a shell command in {@link FileSystem.cwd} unless `options.cwd` is provided. */ + exec( + command: string, + options?: ExecutionEnvExecOptions, + ): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>>; + /** Release shell resources. Must be best-effort and must not throw or reject. */ + cleanup(): Promise<void>; +} + +/** Filesystem and process execution environment used by the harness. */ +export interface ExecutionEnv extends FileSystem, Shell {} + +export interface SessionTreeEntryBase { + type: string; + id: string; + parentId: string | null; + timestamp: string; +} + +export interface MessageEntry extends SessionTreeEntryBase { + type: "message"; + message: AgentMessage; +} + +export interface ThinkingLevelChangeEntry extends SessionTreeEntryBase { + type: "thinking_level_change"; + thinkingLevel: string; +} + +export interface ModelChangeEntry extends SessionTreeEntryBase { + type: "model_change"; + provider: string; + modelId: string; +} + +export interface CompactionEntry<T = unknown> extends SessionTreeEntryBase { + type: "compaction"; + summary: string; + firstKeptEntryId: string; + tokensBefore: number; + details?: T; + fromHook?: boolean; +} + +export interface BranchSummaryEntry<T = unknown> extends SessionTreeEntryBase { + type: "branch_summary"; + fromId: string; + summary: string; + details?: T; + fromHook?: boolean; +} + +export interface CustomEntry<T = unknown> extends SessionTreeEntryBase { + type: "custom"; + customType: string; + data?: T; +} + +export interface CustomMessageEntry<T = unknown> extends SessionTreeEntryBase { + type: "custom_message"; + customType: string; + content: string | (TextContent | ImageContent)[]; + details?: T; + display: boolean; +} + +export interface LabelEntry extends SessionTreeEntryBase { + type: "label"; + targetId: string; + label: string | undefined; +} + +export interface SessionInfoEntry extends SessionTreeEntryBase { + type: "session_info"; // legacy name, kept for backwards compatibility + name?: string; +} + +export interface LeafEntry extends SessionTreeEntryBase { + type: "leaf"; + targetId: string | null; +} + +export type SessionTreeEntry = + | MessageEntry + | ThinkingLevelChangeEntry + | ModelChangeEntry + | CompactionEntry + | BranchSummaryEntry + | CustomEntry + | CustomMessageEntry + | LabelEntry + | SessionInfoEntry + | LeafEntry; + +export interface SessionContext { + messages: AgentMessage[]; + thinkingLevel: string; + model: { provider: string; modelId: string } | null; +} + +export interface SessionMetadata { + id: string; + createdAt: string; +} + +export interface JsonlSessionMetadata extends SessionMetadata { + cwd: string; + path: string; + parentSessionPath?: string; +} + +export interface SessionStorage<TMetadata extends SessionMetadata = SessionMetadata> { + getMetadata(): Promise<TMetadata>; + getLeafId(): Promise<string | null>; + /** Persist a leaf entry that records the active session-tree leaf. */ + setLeafId(leafId: string | null): Promise<void>; + createEntryId(): Promise<string>; + appendEntry(entry: SessionTreeEntry): Promise<void>; + getEntry(id: string): Promise<SessionTreeEntry | undefined>; + findEntries<TType extends SessionTreeEntry["type"]>( + type: TType, + ): Promise<Array<Extract<SessionTreeEntry, { type: TType }>>>; + getLabel(id: string): Promise<string | undefined>; + getPathToRoot(leafId: string | null): Promise<SessionTreeEntry[]>; + getEntries(): Promise<SessionTreeEntry[]>; +} + +export type { Session } from "./session/session"; + +export interface SessionCreateOptions { + id?: string; +} + +export interface SessionForkOptions { + entryId?: string; + position?: "before" | "at"; + id?: string; +} + +export interface SessionRepo< + TMetadata extends SessionMetadata = SessionMetadata, + TCreateOptions extends SessionCreateOptions = SessionCreateOptions, + TListOptions = void, +> { + create(options: TCreateOptions): Promise<Session<TMetadata>>; + open(metadata: TMetadata): Promise<Session<TMetadata>>; + list(options?: TListOptions): Promise<TMetadata[]>; + delete(metadata: TMetadata): Promise<void>; + fork(source: TMetadata, options: SessionForkOptions & TCreateOptions): Promise<Session<TMetadata>>; +} + +export interface JsonlSessionCreateOptions extends SessionCreateOptions { + cwd: string; + parentSessionPath?: string; +} + +export interface JsonlSessionListOptions { + cwd?: string; +} + +export interface JsonlSessionRepoApi + extends SessionRepo<JsonlSessionMetadata, JsonlSessionCreateOptions, JsonlSessionListOptions> {} + +export type AgentHarnessPhase = "idle" | "turn" | "compaction" | "branch_summary" | "retry"; + +export type PendingSessionWrite = SessionTreeEntry extends infer TEntry + ? TEntry extends SessionTreeEntry + ? Omit<TEntry, "id" | "parentId" | "timestamp"> + : never + : never; + +export interface QueueUpdateEvent { + type: "queue_update"; + steer: AgentMessage[]; + followUp: AgentMessage[]; + nextTurn: AgentMessage[]; +} + +export interface SavePointEvent { + type: "save_point"; + hadPendingMutations: boolean; +} + +export interface AbortEvent { + type: "abort"; + clearedSteer: AgentMessage[]; + clearedFollowUp: AgentMessage[]; +} + +export interface SettledEvent { + type: "settled"; + nextTurnCount: number; +} + +export interface BeforeAgentStartEvent< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, +> { + type: "before_agent_start"; + prompt: string; + images?: ImageContent[]; + systemPrompt: string; + resources: AgentHarnessResources<TSkill, TPromptTemplate>; +} + +export interface ContextEvent { + type: "context"; + messages: AgentMessage[]; +} + +export interface BeforeProviderRequestEvent { + type: "before_provider_request"; + model: Model<any>; + sessionId: string; + streamOptions: AgentHarnessStreamOptions; +} + +export interface BeforeProviderPayloadEvent { + type: "before_provider_payload"; + model: Model<any>; + payload: unknown; +} + +export interface AfterProviderResponseEvent { + type: "after_provider_response"; + status: number; + headers: Record<string, string>; +} + +export interface ToolCallEvent { + type: "tool_call"; + toolCallId: string; + toolName: string; + input: Record<string, unknown>; +} + +export interface ToolResultEvent { + type: "tool_result"; + toolCallId: string; + toolName: string; + input: Record<string, unknown>; + content: Array<TextContent | ImageContent>; + details: unknown; + isError: boolean; +} + +export interface SessionBeforeCompactEvent { + type: "session_before_compact"; + preparation: CompactionPreparation; + branchEntries: SessionTreeEntry[]; + customInstructions?: string; + signal: AbortSignal; +} + +export interface SessionCompactEvent { + type: "session_compact"; + compactionEntry: CompactionEntry; + fromHook: boolean; +} + +export interface SessionBeforeTreeEvent { + type: "session_before_tree"; + preparation: TreePreparation; + signal: AbortSignal; +} + +export interface SessionTreeEvent { + type: "session_tree"; + newLeafId: string | null; + oldLeafId: string | null; + summaryEntry?: BranchSummaryEntry; + fromHook?: boolean; +} + +export interface ModelSelectEvent { + type: "model_select"; + model: Model<any>; + previousModel: Model<any> | undefined; + source: "set" | "restore"; +} + +export interface ThinkingLevelSelectEvent { + type: "thinking_level_select"; + level: ThinkingLevel; + previousLevel: ThinkingLevel; +} + +export interface ResourcesUpdateEvent< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, +> { + type: "resources_update"; + resources: AgentHarnessResources<TSkill, TPromptTemplate>; + previousResources: AgentHarnessResources<TSkill, TPromptTemplate>; +} + +export type AgentHarnessOwnEvent< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, +> = + | QueueUpdateEvent + | SavePointEvent + | AbortEvent + | SettledEvent + | BeforeAgentStartEvent<TSkill, TPromptTemplate> + | ContextEvent + | BeforeProviderRequestEvent + | BeforeProviderPayloadEvent + | AfterProviderResponseEvent + | ToolCallEvent + | ToolResultEvent + | SessionBeforeCompactEvent + | SessionCompactEvent + | SessionBeforeTreeEvent + | SessionTreeEvent + | ModelSelectEvent + | ThinkingLevelSelectEvent + | ResourcesUpdateEvent<TSkill, TPromptTemplate>; + +export type AgentHarnessEvent<TSkill extends Skill = Skill, TPromptTemplate extends PromptTemplate = PromptTemplate> = + | AgentEvent + | AgentHarnessOwnEvent<TSkill, TPromptTemplate>; + +export interface BeforeAgentStartResult { + messages?: AgentMessage[]; + systemPrompt?: string; +} + +export interface ContextResult { + messages: AgentMessage[]; +} + +export interface BeforeProviderRequestResult { + streamOptions?: AgentHarnessStreamOptionsPatch; +} + +export interface BeforeProviderPayloadResult { + payload: unknown; +} + +export interface ToolCallResult { + block?: boolean; + reason?: string; +} + +export interface ToolResultPatch { + content?: Array<TextContent | ImageContent>; + details?: unknown; + isError?: boolean; + terminate?: boolean; +} + +export interface SessionBeforeCompactResult { + cancel?: boolean; + compaction?: CompactResult; +} + +export interface SessionBeforeTreeResult { + cancel?: boolean; + summary?: { summary: string; details?: unknown }; + customInstructions?: string; + replaceInstructions?: boolean; + label?: string; +} + +export type AgentHarnessEventResultMap = { + before_agent_start: BeforeAgentStartResult | undefined; + context: ContextResult | undefined; + before_provider_request: BeforeProviderRequestResult | undefined; + before_provider_payload: BeforeProviderPayloadResult | undefined; + after_provider_response: undefined; + tool_call: ToolCallResult | undefined; + tool_result: ToolResultPatch | undefined; + session_before_compact: SessionBeforeCompactResult | undefined; + session_compact: undefined; + session_before_tree: SessionBeforeTreeResult | undefined; + session_tree: undefined; + model_select: undefined; + thinking_level_select: undefined; + resources_update: undefined; + queue_update: undefined; + save_point: undefined; + abort: undefined; + settled: undefined; +}; + +export interface AgentHarnessPromptOptions { + images?: ImageContent[]; +} + +export interface AbortResult { + clearedSteer: AgentMessage[]; + clearedFollowUp: AgentMessage[]; +} + +export interface CompactResult { + summary: string; + firstKeptEntryId: string; + tokensBefore: number; + details?: unknown; +} + +export interface NavigateTreeResult { + cancelled: boolean; + editorText?: string; + summaryEntry?: BranchSummaryEntry; +} + +export interface CompactionSettings { + enabled: boolean; + reserveTokens: number; + keepRecentTokens: number; +} + +export interface CompactionPreparation { + firstKeptEntryId: string; + messagesToSummarize: AgentMessage[]; + turnPrefixMessages: AgentMessage[]; + isSplitTurn: boolean; + tokensBefore: number; + previousSummary?: string; + fileOps: FileOperations; + settings: CompactionSettings; +} + +export interface FileOperations { + read: Set<string>; + written: Set<string>; + edited: Set<string>; +} + +export interface TreePreparation { + targetId: string; + oldLeafId: string | null; + commonAncestorId: string | null; + entriesToSummarize: SessionTreeEntry[]; + userWantsSummary: boolean; + customInstructions?: string; + replaceInstructions?: boolean; + label?: string; +} + +export interface GenerateBranchSummaryOptions { + model: Model<any>; + apiKey: string; + headers?: Record<string, string>; + signal: AbortSignal; + customInstructions?: string; + replaceInstructions?: boolean; + reserveTokens?: number; +} + +export interface BranchSummaryResult { + summary: string; + readFiles: string[]; + modifiedFiles: string[]; +} + +export interface AgentHarnessOptions< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, + TTool extends AgentTool = AgentTool, +> { + env: ExecutionEnv; + session: Session; + tools?: TTool[]; + /** + * Concrete resources available to explicit invocation methods and system-prompt callbacks. + * Applications own loading/reloading resources and should call `setResources()` with new values. + */ + resources?: AgentHarnessResources<TSkill, TPromptTemplate>; + systemPrompt?: + | string + | ((context: { + env: ExecutionEnv; + session: Session; + model: Model<any>; + thinkingLevel: ThinkingLevel; + activeTools: TTool[]; + resources: AgentHarnessResources<TSkill, TPromptTemplate>; + }) => string | Promise<string>); + getApiKeyAndHeaders?: ( + model: Model<any>, + ) => Promise<{ apiKey: string; headers?: Record<string, string> } | undefined>; + /** Curated stream/provider request options. Snapshotted at turn start. */ + streamOptions?: AgentHarnessStreamOptions; + model: Model<any>; + thinkingLevel?: ThinkingLevel; + activeToolNames?: string[]; + steeringMode?: QueueMode; + followUpMode?: QueueMode; +} + +export type { AgentHarness } from "./agent-harness"; diff --git a/emain/agent/harness/utils/shell-output.ts b/emain/agent/harness/utils/shell-output.ts new file mode 100644 index 000000000..65976daee --- /dev/null +++ b/emain/agent/harness/utils/shell-output.ts @@ -0,0 +1,143 @@ +import { + type ExecutionEnv, + type ExecutionEnvExecOptions, + ExecutionError, + err, + ok, + type Result, + toError, +} from "../types"; +import { DEFAULT_MAX_BYTES, truncateTail } from "./truncate"; + +export interface ShellCaptureOptions extends Omit<ExecutionEnvExecOptions, "onStdout" | "onStderr"> { + onChunk?: (chunk: string) => void; +} + +export interface ShellCaptureResult { + output: string; + exitCode: number | undefined; + cancelled: boolean; + truncated: boolean; + fullOutputPath?: string; +} + +function toExecutionError(error: unknown): ExecutionError { + if (error instanceof ExecutionError) return error; + const cause = toError(error); + return new ExecutionError("unknown", cause.message, cause); +} + +export function sanitizeBinaryOutput(str: string): string { + return Array.from(str) + .filter((char) => { + const code = char.codePointAt(0); + if (code === undefined) return false; + if (code === 0x09 || code === 0x0a || code === 0x0d) return true; + if (code <= 0x1f) return false; + if (code >= 0xfff9 && code <= 0xfffb) return false; + return true; + }) + .join(""); +} + +export async function executeShellWithCapture( + env: ExecutionEnv, + command: string, + options?: ShellCaptureOptions, +): Promise<Result<ShellCaptureResult, ExecutionError>> { + const outputChunks: string[] = []; + let outputBytes = 0; + const maxOutputBytes = DEFAULT_MAX_BYTES * 2; + const encoder = new TextEncoder(); + + let totalBytes = 0; + let fullOutputPath: string | undefined; + let writeChain: Promise<Result<void, ExecutionError>> = Promise.resolve(ok(undefined)); + let captureError: ExecutionError | undefined; + + const appendFullOutput = (text: string): void => { + if (!fullOutputPath || captureError) return; + const path = fullOutputPath; + writeChain = writeChain.then(async (previous) => { + if (!previous.ok) return previous; + const appendResult = await env.appendFile(path, text, options?.abortSignal); + return appendResult.ok ? ok(undefined) : err(toExecutionError(appendResult.error)); + }); + }; + + const ensureFullOutputFile = (initialContent: string): void => { + if (fullOutputPath || captureError) return; + writeChain = writeChain.then(async (previous) => { + if (!previous.ok) return previous; + const tempFile = await env.createTempFile({ + prefix: "bash-", + suffix: ".log", + abortSignal: options?.abortSignal, + }); + if (!tempFile.ok) return err(toExecutionError(tempFile.error)); + fullOutputPath = tempFile.value; + const appendResult = await env.appendFile(tempFile.value, initialContent, options?.abortSignal); + return appendResult.ok ? ok(undefined) : err(toExecutionError(appendResult.error)); + }); + }; + + const onChunk = (chunk: string) => { + try { + totalBytes += encoder.encode(chunk).byteLength; + const text = sanitizeBinaryOutput(chunk).replace(/\r/g, ""); + if (totalBytes > DEFAULT_MAX_BYTES && !fullOutputPath) { + ensureFullOutputFile(outputChunks.join("") + text); + } else { + appendFullOutput(text); + } + outputChunks.push(text); + outputBytes += text.length; + while (outputBytes > maxOutputBytes && outputChunks.length > 1) { + const removed = outputChunks.shift()!; + outputBytes -= removed.length; + } + options?.onChunk?.(text); + } catch (error) { + captureError = toExecutionError(error); + } + }; + + try { + const result = await env.exec(command, { + ...(options ?? {}), + onStdout: onChunk, + onStderr: onChunk, + }); + const tailOutput = outputChunks.join(""); + const truncationResult = truncateTail(tailOutput); + if (truncationResult.truncated && !fullOutputPath) { + ensureFullOutputFile(tailOutput); + } + const writeResult = await writeChain; + if (!writeResult.ok) return err(writeResult.error); + if (captureError) return err(captureError); + + if (!result.ok) { + if (result.error.code === "aborted" || options?.abortSignal?.aborted) { + return ok({ + output: truncationResult.truncated ? truncationResult.content : tailOutput, + exitCode: undefined, + cancelled: true, + truncated: truncationResult.truncated, + fullOutputPath, + }); + } + return err(result.error); + } + const cancelled = options?.abortSignal?.aborted ?? false; + return ok({ + output: truncationResult.truncated ? truncationResult.content : tailOutput, + exitCode: cancelled ? undefined : result.value.exitCode, + cancelled, + truncated: truncationResult.truncated, + fullOutputPath, + }); + } catch (error) { + return err(toExecutionError(error)); + } +} diff --git a/emain/agent/harness/utils/truncate.ts b/emain/agent/harness/utils/truncate.ts new file mode 100644 index 000000000..2372a941b --- /dev/null +++ b/emain/agent/harness/utils/truncate.ts @@ -0,0 +1,344 @@ +/** + * Shared truncation utilities for tool outputs. + * + * Truncation is based on two independent limits - whichever is hit first wins: + * - Line limit (default: 2000 lines) + * - Byte limit (default: 50KB) + * + * Never returns partial lines (except bash tail truncation edge case). + */ + +export const DEFAULT_MAX_LINES = 2000; +export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB +export const GREP_MAX_LINE_LENGTH = 500; // Max chars per grep match line + +export interface TruncationResult { + /** The truncated content */ + content: string; + /** Whether truncation occurred */ + truncated: boolean; + /** Which limit was hit: "lines", "bytes", or null if not truncated */ + truncatedBy: "lines" | "bytes" | null; + /** Total number of lines in the original content */ + totalLines: number; + /** Total number of bytes in the original content */ + totalBytes: number; + /** Number of complete lines in the truncated output */ + outputLines: number; + /** Number of bytes in the truncated output */ + outputBytes: number; + /** Whether the last line was partially truncated (only for tail truncation edge case) */ + lastLinePartial: boolean; + /** Whether the first line exceeded the byte limit (for head truncation) */ + firstLineExceedsLimit: boolean; + /** The max lines limit that was applied */ + maxLines: number; + /** The max bytes limit that was applied */ + maxBytes: number; +} + +export interface TruncationOptions { + /** Maximum number of lines (default: 2000) */ + maxLines?: number; + /** Maximum number of bytes (default: 50KB) */ + maxBytes?: number; +} + +interface RuntimeBuffer { + byteLength(content: string, encoding: "utf8"): number; +} + +const runtimeBuffer = (globalThis as { Buffer?: RuntimeBuffer }).Buffer; +const nonAsciiPattern = /[^\x00-\x7f]/; + +function utf8ByteLength(content: string): number { + if (runtimeBuffer) return runtimeBuffer.byteLength(content, "utf8"); + + const firstNonAscii = content.search(nonAsciiPattern); + if (firstNonAscii === -1) return content.length; + + let bytes = firstNonAscii; + for (let i = firstNonAscii; i < content.length; i++) { + const code = content.charCodeAt(i); + if (code <= 0x7f) { + bytes += 1; + } else if (code <= 0x7ff) { + bytes += 2; + } else if (code >= 0xd800 && code <= 0xdbff && i + 1 < content.length) { + const next = content.charCodeAt(i + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + bytes += 4; + i++; + } else { + bytes += 3; + } + } else { + bytes += 3; + } + } + return bytes; +} + +function replaceUnpairedSurrogates(content: string): string { + let output = ""; + for (let i = 0; i < content.length; i++) { + const code = content.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff) { + if (i + 1 < content.length) { + const next = content.charCodeAt(i + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + output += content[i] + content[i + 1]; + i++; + continue; + } + } + output += "īŋŊ"; + } else if (code >= 0xdc00 && code <= 0xdfff) { + output += "īŋŊ"; + } else { + output += content[i]; + } + } + return output; +} + +/** + * Format bytes as human-readable size. + */ +export function formatSize(bytes: number): string { + if (bytes < 1024) { + return `${bytes}B`; + } else if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)}KB`; + } else { + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; + } +} + +/** + * Truncate content from the head (keep first N lines/bytes). + * Suitable for file reads where you want to see the beginning. + * + * Never returns partial lines. If first line exceeds byte limit, + * returns empty content with firstLineExceedsLimit=true. + */ +export function truncateHead(content: string, options: TruncationOptions = {}): TruncationResult { + const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + + const totalBytes = utf8ByteLength(content); + const lines = content.split("\n"); + const totalLines = lines.length; + + // Check if no truncation needed + if (totalLines <= maxLines && totalBytes <= maxBytes) { + return { + content, + truncated: false, + truncatedBy: null, + totalLines, + totalBytes, + outputLines: totalLines, + outputBytes: totalBytes, + lastLinePartial: false, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + }; + } + + // Check if first line alone exceeds byte limit + const firstLineBytes = utf8ByteLength(lines[0]); + if (firstLineBytes > maxBytes) { + return { + content: "", + truncated: true, + truncatedBy: "bytes", + totalLines, + totalBytes, + outputLines: 0, + outputBytes: 0, + lastLinePartial: false, + firstLineExceedsLimit: true, + maxLines, + maxBytes, + }; + } + + // Collect complete lines that fit + const outputLinesArr: string[] = []; + let outputBytesCount = 0; + let truncatedBy: "lines" | "bytes" = "lines"; + + for (let i = 0; i < lines.length && i < maxLines; i++) { + const line = lines[i]; + const lineBytes = utf8ByteLength(line) + (i > 0 ? 1 : 0); // +1 for newline + + if (outputBytesCount + lineBytes > maxBytes) { + truncatedBy = "bytes"; + break; + } + + outputLinesArr.push(line); + outputBytesCount += lineBytes; + } + + // If we exited due to line limit + if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) { + truncatedBy = "lines"; + } + + const outputContent = outputLinesArr.join("\n"); + const finalOutputBytes = utf8ByteLength(outputContent); + + return { + content: outputContent, + truncated: true, + truncatedBy, + totalLines, + totalBytes, + outputLines: outputLinesArr.length, + outputBytes: finalOutputBytes, + lastLinePartial: false, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + }; +} + +/** + * Truncate content from the tail (keep last N lines/bytes). + * Suitable for bash output where you want to see the end (errors, final results). + * + * May return partial first line if the last line of original content exceeds byte limit. + */ +export function truncateTail(content: string, options: TruncationOptions = {}): TruncationResult { + const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + + const totalBytes = utf8ByteLength(content); + const lines = content.split("\n"); + if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop(); + const totalLines = lines.length; + + // Check if no truncation needed + if (totalLines <= maxLines && totalBytes <= maxBytes) { + return { + content, + truncated: false, + truncatedBy: null, + totalLines, + totalBytes, + outputLines: totalLines, + outputBytes: totalBytes, + lastLinePartial: false, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + }; + } + + // Work backwards from the end + const outputLinesArr: string[] = []; + let outputBytesCount = 0; + let truncatedBy: "lines" | "bytes" = "lines"; + let lastLinePartial = false; + + for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) { + const line = lines[i]; + const lineBytes = utf8ByteLength(line) + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline + + if (outputBytesCount + lineBytes > maxBytes) { + truncatedBy = "bytes"; + // Edge case: if we haven't added ANY lines yet and this line exceeds maxBytes, + // take the end of the line (partial) + if (outputLinesArr.length === 0) { + const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes); + outputLinesArr.unshift(truncatedLine); + outputBytesCount = utf8ByteLength(truncatedLine); + lastLinePartial = true; + } + break; + } + + outputLinesArr.unshift(line); + outputBytesCount += lineBytes; + } + + // If we exited due to line limit + if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) { + truncatedBy = "lines"; + } + + const outputContent = outputLinesArr.join("\n"); + const finalOutputBytes = utf8ByteLength(outputContent); + + return { + content: outputContent, + truncated: true, + truncatedBy, + totalLines, + totalBytes, + outputLines: outputLinesArr.length, + outputBytes: finalOutputBytes, + lastLinePartial, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + }; +} + +/** + * Truncate a string to fit within a byte limit (from the end). + * Handles multi-byte UTF-8 characters correctly. + */ +function truncateStringToBytesFromEnd(str: string, maxBytes: number): string { + if (maxBytes <= 0) return ""; + + let outputBytes = 0; + let start = str.length; + let needsReplacement = false; + for (let i = str.length; i > 0; ) { + let characterStart = i - 1; + const code = str.charCodeAt(characterStart); + let characterBytes: number; + let unpairedSurrogate = false; + if (code >= 0xdc00 && code <= 0xdfff && characterStart > 0) { + const previous = str.charCodeAt(characterStart - 1); + if (previous >= 0xd800 && previous <= 0xdbff) { + characterStart--; + characterBytes = 4; + } else { + characterBytes = 3; + unpairedSurrogate = true; + } + } else if (code >= 0xd800 && code <= 0xdfff) { + characterBytes = 3; + unpairedSurrogate = true; + } else { + characterBytes = code <= 0x7f ? 1 : code <= 0x7ff ? 2 : 3; + } + if (outputBytes + characterBytes > maxBytes) break; + outputBytes += characterBytes; + start = characterStart; + needsReplacement ||= unpairedSurrogate; + i = characterStart; + } + + const output = str.slice(start); + return needsReplacement ? replaceUnpairedSurrogates(output) : output; +} + +/** + * Truncate a single line to max characters, adding [truncated] suffix. + * Used for grep match lines. + */ +export function truncateLine( + line: string, + maxChars: number = GREP_MAX_LINE_LENGTH, +): { text: string; wasTruncated: boolean } { + if (line.length <= maxChars) { + return { text: line, wasTruncated: false }; + } + return { text: `${line.slice(0, maxChars)}... [truncated]`, wasTruncated: true }; +} diff --git a/emain/agent/index.ts b/emain/agent/index.ts new file mode 100644 index 000000000..31d9a6c2e --- /dev/null +++ b/emain/agent/index.ts @@ -0,0 +1,44 @@ +// Core Agent +export * from "./agent"; +// Loop functions +export * from "./agent-loop"; +export * from "./harness/agent-harness"; +export { + type BranchPreparation, + type BranchSummaryDetails, + type CollectEntriesResult, + collectEntriesForBranchSummary, + generateBranchSummary, + prepareBranchEntries, +} from "./harness/compaction/branch-summarization"; +export { + calculateContextTokens, + compact, + DEFAULT_COMPACTION_SETTINGS, + estimateContextTokens, + estimateTokens, + findCutPoint, + findTurnStartIndex, + generateSummary, + getLastAssistantUsage, + prepareCompaction, + serializeConversation, + shouldCompact, +} from "./harness/compaction/compaction"; +export * from "./harness/messages"; +export * from "./harness/prompt-templates"; +export * from "./harness/session/jsonl-repo"; +export * from "./harness/session/memory-repo"; +export * from "./harness/session/repo-utils"; +export * from "./harness/session/session"; +export { uuidv7 } from "./harness/session/uuid"; +export * from "./harness/skills"; +export * from "./harness/system-prompt"; +// Harness +export * from "./harness/types"; +export * from "./harness/utils/shell-output"; +export * from "./harness/utils/truncate"; +// Proxy utilities +export * from "./proxy"; +// Types +export * from "./types"; diff --git a/emain/agent/node.ts b/emain/agent/node.ts new file mode 100644 index 000000000..87ee99d35 --- /dev/null +++ b/emain/agent/node.ts @@ -0,0 +1,2 @@ +export { NodeExecutionEnv } from "./harness/env/nodejs"; +export * from "./index"; diff --git a/emain/agent/permissions.test.ts b/emain/agent/permissions.test.ts new file mode 100644 index 000000000..1f5fa81ee --- /dev/null +++ b/emain/agent/permissions.test.ts @@ -0,0 +1,83 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, describe, expect, it } from "vitest"; + +import type { ToolCallEvent } from "./harness/types"; +import { buildPermissionsHook, isBenchMode } from "./permissions"; + +function fakeToolCall(toolName: string): ToolCallEvent { + return { + type: "tool_call", + toolCallId: "tc-1", + toolName, + input: {}, + }; +} + +describe("buildPermissionsHook", () => { + it("allows every call when allowAll is true (default)", async () => { + const hook = buildPermissionsHook(); + expect(await hook(fakeToolCall("anything"))).toBeUndefined(); + expect(await hook(fakeToolCall("rm-rf"))).toBeUndefined(); + }); + + it("allows every call when allowAll is explicitly true", async () => { + const hook = buildPermissionsHook({ allowAll: true, allowedTools: ["only_this"] }); + // allowAll wins; allowedTools is ignored. + expect(await hook(fakeToolCall("not_listed"))).toBeUndefined(); + }); + + it("enforces the allowlist when allowAll is false", async () => { + const hook = buildPermissionsHook({ + allowAll: false, + allowedTools: ["read_file", "list_dir"], + }); + expect(await hook(fakeToolCall("read_file"))).toBeUndefined(); + expect(await hook(fakeToolCall("list_dir"))).toBeUndefined(); + const blocked = await hook(fakeToolCall("shell_exec")); + expect(blocked).toEqual({ + block: true, + reason: expect.stringContaining("shell_exec"), + }); + }); + + it("blocks all tools when allowAll is false and allowedTools is empty/missing", async () => { + const hook = buildPermissionsHook({ allowAll: false }); + const result = await hook(fakeToolCall("read_file")); + expect(result?.block).toBe(true); + }); + + it("includes the tool name in the block reason for actionable errors", async () => { + const hook = buildPermissionsHook({ allowAll: false, allowedTools: [] }); + const result = await hook(fakeToolCall("custom_tool")); + expect(result?.reason).toContain('"custom_tool"'); + }); +}); + +describe("isBenchMode", () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("returns true when CREST_AGENT_BENCH=1", () => { + process.env.CREST_AGENT_BENCH = "1"; + expect(isBenchMode()).toBe(true); + }); + + it("returns false when CREST_AGENT_BENCH is unset", () => { + delete process.env.CREST_AGENT_BENCH; + expect(isBenchMode()).toBe(false); + }); + + it("returns false when CREST_AGENT_BENCH is anything other than '1'", () => { + process.env.CREST_AGENT_BENCH = "true"; + expect(isBenchMode()).toBe(false); + process.env.CREST_AGENT_BENCH = "yes"; + expect(isBenchMode()).toBe(false); + process.env.CREST_AGENT_BENCH = ""; + expect(isBenchMode()).toBe(false); + }); +}); diff --git a/emain/agent/permissions.ts b/emain/agent/permissions.ts new file mode 100644 index 000000000..7ee8dc181 --- /dev/null +++ b/emain/agent/permissions.ts @@ -0,0 +1,79 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +// +// permissions.ts — per-pane tool gating via pi's AgentHarness +// "tool_call" event hook. Replaces the deleted Go pkg/agent/permissions/ +// posture-and-rules engine with a small allowlist + bench-mode bypass. +// See docs/agent-runtime-architecture.md §7.9 for the decision to drop +// posture (1500 LOC Go) in favor of this 50-line shape. +// +// Note: AgentHarness exposes its tool gate via the typed `.on("tool_call", ...)` +// handler (not pi Agent's bare `beforeToolCall` constructor option). The +// callback gets ToolCallEvent and returns ToolCallResult — slimmer shape +// than pi Agent's BeforeToolCallContext, which is what we need anyway. +// +// v1 semantics: +// - allowAll: true → every tool call passes (default; the +// renderer UX for selective approval is not +// wired yet, so the agent stays functional) +// - allowedTools: [...] → only listed names pass; others get a +// block:true response with a readable reason +// that surfaces as the tool result content +// (and thus appears inline in the agent block) +// +// Future work (not v1): +// - Interactive "approve this tool call?" UI — return a Promise that +// resolves on user click. Requires task #12's IPC channel for +// prompt → renderer-click → response round-trip. +// - Per-tool-args matching (e.g. allow `bash` for `git status` but +// not `rm -rf`) — the dropped posture/rules engine did this; add +// per-pattern matching in this file when actually needed, without +// resurrecting the full posture machinery. + +import type { ToolCallEvent, ToolCallResult } from "./harness/types"; + +export interface PermissionsConfig { + /** + * Tool names pre-approved for this pane. Ignored when allowAll is true. + * Names match the `name` field of AgentTool definitions registered + * with the harness. + */ + allowedTools?: string[]; + /** + * When true, every tool call passes (no allowlist check). Set by + * the bench harness via CREST_AGENT_BENCH=1 so eval runs aren't + * gated by per-tool approvals. Defaults to true in v1 because no + * approval UI exists yet — agent functionality must not regress. + */ + allowAll?: boolean; +} + +export type ToolCallHook = ( + event: ToolCallEvent, +) => Promise<ToolCallResult | undefined>; + +/** + * Construct a tool_call handler compatible with AgentHarness's + * .on("tool_call", ...) registration. Returns undefined to allow + * the call, or {block: true, reason} to deny. + */ +export function buildPermissionsHook(config: PermissionsConfig = {}): ToolCallHook { + const allowAll = config.allowAll ?? true; + const allowSet = new Set(config.allowedTools ?? []); + return async (event) => { + if (allowAll) return undefined; + if (allowSet.has(event.toolName)) return undefined; + return { + block: true, + reason: `Tool "${event.toolName}" is not allowed for this session.`, + }; + }; +} + +/** + * Read CREST_AGENT_BENCH from the environment. Set by the eval harness + * (and tests) to disable allowlist enforcement. + */ +export function isBenchMode(): boolean { + return process.env.CREST_AGENT_BENCH === "1"; +} diff --git a/emain/agent/proxy.ts b/emain/agent/proxy.ts new file mode 100644 index 000000000..8f952a74c --- /dev/null +++ b/emain/agent/proxy.ts @@ -0,0 +1,367 @@ +/** + * Proxy stream function for apps that route LLM calls through a server. + * The server manages auth and proxies requests to LLM providers. + */ + +// Internal import for JSON parsing utility +import { + type AssistantMessage, + type AssistantMessageEvent, + type Context, + EventStream, + type Model, + parseStreamingJson, + type SimpleStreamOptions, + type StopReason, + type ToolCall, +} from "../ai"; + +// Create stream class matching ProxyMessageEventStream +class ProxyMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> { + constructor() { + super( + (event) => event.type === "done" || event.type === "error", + (event) => { + if (event.type === "done") return event.message; + if (event.type === "error") return event.error; + throw new Error("Unexpected event type"); + }, + ); + } +} + +/** + * Proxy event types - server sends these with partial field stripped to reduce bandwidth. + */ +export type ProxyAssistantMessageEvent = + | { type: "start" } + | { type: "text_start"; contentIndex: number } + | { type: "text_delta"; contentIndex: number; delta: string } + | { type: "text_end"; contentIndex: number; contentSignature?: string } + | { type: "thinking_start"; contentIndex: number } + | { type: "thinking_delta"; contentIndex: number; delta: string } + | { type: "thinking_end"; contentIndex: number; contentSignature?: string } + | { type: "toolcall_start"; contentIndex: number; id: string; toolName: string } + | { type: "toolcall_delta"; contentIndex: number; delta: string } + | { type: "toolcall_end"; contentIndex: number } + | { + type: "done"; + reason: Extract<StopReason, "stop" | "length" | "toolUse">; + usage: AssistantMessage["usage"]; + } + | { + type: "error"; + reason: Extract<StopReason, "aborted" | "error">; + errorMessage?: string; + usage: AssistantMessage["usage"]; + }; + +type ProxySerializableStreamOptions = Pick< + SimpleStreamOptions, + | "temperature" + | "maxTokens" + | "reasoning" + | "cacheRetention" + | "sessionId" + | "headers" + | "metadata" + | "transport" + | "thinkingBudgets" + | "maxRetryDelayMs" +>; + +export interface ProxyStreamOptions extends ProxySerializableStreamOptions { + /** Local abort signal for the proxy request */ + signal?: AbortSignal; + /** Auth token for the proxy server */ + authToken: string; + /** Proxy server URL (e.g., "https://genai.example.com") */ + proxyUrl: string; +} + +/** + * Stream function that proxies through a server instead of calling LLM providers directly. + * The server strips the partial field from delta events to reduce bandwidth. + * We reconstruct the partial message client-side. + * + * Use this as the `streamFn` option when creating an Agent that needs to go through a proxy. + * + * @example + * ```typescript + * const agent = new Agent({ + * streamFn: (model, context, options) => + * streamProxy(model, context, { + * ...options, + * authToken: await getAuthToken(), + * proxyUrl: "https://genai.example.com", + * }), + * }); + * ``` + */ +function buildProxyRequestOptions(options: ProxyStreamOptions): ProxySerializableStreamOptions { + return { + temperature: options.temperature, + maxTokens: options.maxTokens, + reasoning: options.reasoning, + cacheRetention: options.cacheRetention, + sessionId: options.sessionId, + headers: options.headers, + metadata: options.metadata, + transport: options.transport, + thinkingBudgets: options.thinkingBudgets, + maxRetryDelayMs: options.maxRetryDelayMs, + }; +} + +export function streamProxy(model: Model<any>, context: Context, options: ProxyStreamOptions): ProxyMessageEventStream { + const stream = new ProxyMessageEventStream(); + + (async () => { + // Initialize the partial message that we'll build up from events + const partial: AssistantMessage = { + role: "assistant", + stopReason: "stop", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: Date.now(), + }; + + let reader: ReadableStreamDefaultReader<Uint8Array> | undefined; + + const abortHandler = () => { + if (reader) { + reader.cancel("Request aborted by user").catch(() => {}); + } + }; + + if (options.signal) { + options.signal.addEventListener("abort", abortHandler); + } + + try { + const response = await fetch(`${options.proxyUrl}/api/stream`, { + method: "POST", + headers: { + Authorization: `Bearer ${options.authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model, + context, + options: buildProxyRequestOptions(options), + }), + signal: options.signal, + }); + + if (!response.ok) { + let errorMessage = `Proxy error: ${response.status} ${response.statusText}`; + try { + const errorData = (await response.json()) as { error?: string }; + if (errorData.error) { + errorMessage = `Proxy error: ${errorData.error}`; + } + } catch { + // Couldn't parse error response + } + throw new Error(errorMessage); + } + + reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + if (options.signal?.aborted) { + throw new Error("Request aborted by user"); + } + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + const data = line.slice(6).trim(); + if (data) { + const proxyEvent = JSON.parse(data) as ProxyAssistantMessageEvent; + const event = processProxyEvent(proxyEvent, partial); + if (event) { + stream.push(event); + } + } + } + } + } + + if (options.signal?.aborted) { + throw new Error("Request aborted by user"); + } + + stream.end(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const reason = options.signal?.aborted ? "aborted" : "error"; + partial.stopReason = reason; + partial.errorMessage = errorMessage; + stream.push({ + type: "error", + reason, + error: partial, + }); + stream.end(); + } finally { + if (options.signal) { + options.signal.removeEventListener("abort", abortHandler); + } + } + })(); + + return stream; +} + +/** + * Process a proxy event and update the partial message. + */ +function processProxyEvent( + proxyEvent: ProxyAssistantMessageEvent, + partial: AssistantMessage, +): AssistantMessageEvent | undefined { + switch (proxyEvent.type) { + case "start": + return { type: "start", partial }; + + case "text_start": + partial.content[proxyEvent.contentIndex] = { type: "text", text: "" }; + return { type: "text_start", contentIndex: proxyEvent.contentIndex, partial }; + + case "text_delta": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "text") { + content.text += proxyEvent.delta; + return { + type: "text_delta", + contentIndex: proxyEvent.contentIndex, + delta: proxyEvent.delta, + partial, + }; + } + throw new Error("Received text_delta for non-text content"); + } + + case "text_end": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "text") { + content.textSignature = proxyEvent.contentSignature; + return { + type: "text_end", + contentIndex: proxyEvent.contentIndex, + content: content.text, + partial, + }; + } + throw new Error("Received text_end for non-text content"); + } + + case "thinking_start": + partial.content[proxyEvent.contentIndex] = { type: "thinking", thinking: "" }; + return { type: "thinking_start", contentIndex: proxyEvent.contentIndex, partial }; + + case "thinking_delta": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "thinking") { + content.thinking += proxyEvent.delta; + return { + type: "thinking_delta", + contentIndex: proxyEvent.contentIndex, + delta: proxyEvent.delta, + partial, + }; + } + throw new Error("Received thinking_delta for non-thinking content"); + } + + case "thinking_end": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "thinking") { + content.thinkingSignature = proxyEvent.contentSignature; + return { + type: "thinking_end", + contentIndex: proxyEvent.contentIndex, + content: content.thinking, + partial, + }; + } + throw new Error("Received thinking_end for non-thinking content"); + } + + case "toolcall_start": + partial.content[proxyEvent.contentIndex] = { + type: "toolCall", + id: proxyEvent.id, + name: proxyEvent.toolName, + arguments: {}, + partialJson: "", + } satisfies ToolCall & { partialJson: string } as ToolCall; + return { type: "toolcall_start", contentIndex: proxyEvent.contentIndex, partial }; + + case "toolcall_delta": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "toolCall") { + (content as any).partialJson += proxyEvent.delta; + content.arguments = parseStreamingJson((content as any).partialJson) || {}; + partial.content[proxyEvent.contentIndex] = { ...content }; // Trigger reactivity + return { + type: "toolcall_delta", + contentIndex: proxyEvent.contentIndex, + delta: proxyEvent.delta, + partial, + }; + } + throw new Error("Received toolcall_delta for non-toolCall content"); + } + + case "toolcall_end": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "toolCall") { + delete (content as any).partialJson; + return { + type: "toolcall_end", + contentIndex: proxyEvent.contentIndex, + toolCall: content, + partial, + }; + } + return undefined; + } + + case "done": + partial.stopReason = proxyEvent.reason; + partial.usage = proxyEvent.usage; + return { type: "done", reason: proxyEvent.reason, message: partial }; + + case "error": + partial.stopReason = proxyEvent.reason; + partial.errorMessage = proxyEvent.errorMessage; + partial.usage = proxyEvent.usage; + return { type: "error", reason: proxyEvent.reason, error: partial }; + + default: { + const _exhaustiveCheck: never = proxyEvent; + console.warn(`Unhandled proxy event type: ${(proxyEvent as any).type}`); + return undefined; + } + } +} diff --git a/emain/agent/sessions.test.ts b/emain/agent/sessions.test.ts new file mode 100644 index 000000000..b2f49defb --- /dev/null +++ b/emain/agent/sessions.test.ts @@ -0,0 +1,162 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +// +// Tests for sessions.ts + build-system-prompt.ts. Doesn't exercise +// AgentHarness or LLM calls — that's the spike + task #14 E2E. + +import { promises as fs } from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { JsonlSessionRepo } from "./harness/session/jsonl-repo"; +import { NodeExecutionEnv } from "./node"; +import { buildSystemPrompt } from "./build-system-prompt"; +import { + _setSessionsRepoForTests, + createPaneSession, + defaultSessionsDir, + listSessionsForCwd, + openPaneSession, +} from "./sessions"; + +describe("sessions — JsonlSessionRepo wiring", () => { + let tmpRoot: string; + + beforeEach(async () => { + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "crest-sessions-test-")); + const env = new NodeExecutionEnv({ cwd: process.cwd() }); + _setSessionsRepoForTests(new JsonlSessionRepo({ fs: env, sessionsRoot: tmpRoot })); + }); + + afterEach(async () => { + _setSessionsRepoForTests(undefined); + await fs.rm(tmpRoot, { recursive: true, force: true }); + }); + + it("createPaneSession mints metadata with all four required fields", async () => { + const { metadata } = await createPaneSession("/tmp/some-project"); + expect(metadata.id).toMatch(/^[0-9a-f-]{20,}$/i); + expect(metadata.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(metadata.cwd).toBe("/tmp/some-project"); + expect(metadata.path).toContain(tmpRoot); + expect(metadata.path.endsWith(".jsonl")).toBe(true); + }); + + it("createPaneSession writes a JSONL file with a session header on line 1", async () => { + const { metadata } = await createPaneSession("/tmp/proj-a"); + const raw = await fs.readFile(metadata.path, "utf8"); + const firstLine = raw.split("\n")[0]; + const header = JSON.parse(firstLine); + expect(header.type).toBe("session"); + expect(header.id).toBe(metadata.id); + expect(header.cwd).toBe("/tmp/proj-a"); + }); + + it("openPaneSession returns a Session with matching metadata", async () => { + const created = await createPaneSession("/tmp/proj-b"); + const reopened = await openPaneSession(created.metadata); + const reopenedMeta = await reopened.getMetadata(); + expect(reopenedMeta.id).toBe(created.metadata.id); + expect(reopenedMeta.path).toBe(created.metadata.path); + expect(reopenedMeta.cwd).toBe("/tmp/proj-b"); + }); + + it("listSessionsForCwd returns only sessions for the given cwd, newest first", async () => { + const a1 = await createPaneSession("/tmp/proj-x"); + // Brief delay so timestamps differ (pi sorts by createdAt). + await new Promise((r) => setTimeout(r, 10)); + const a2 = await createPaneSession("/tmp/proj-x"); + await new Promise((r) => setTimeout(r, 10)); + await createPaneSession("/tmp/proj-y"); // different cwd; should not appear + + const list = await listSessionsForCwd("/tmp/proj-x"); + expect(list).toHaveLength(2); + // newest first + expect(list[0].id).toBe(a2.metadata.id); + expect(list[1].id).toBe(a1.metadata.id); + }); + + it("listSessionsForCwd returns [] for a cwd with no sessions", async () => { + await createPaneSession("/tmp/proj-other"); + const list = await listSessionsForCwd("/tmp/never-touched"); + expect(list).toEqual([]); + }); + + it("AgentSessionMeta shape matches JsonlSessionMetadata fields", async () => { + // Tests the doc §5.1 promise: AgentSessionMeta (from Go-generated TS) + // is structurally a subset of pi's JsonlSessionMetadata, so round-trip + // is identity. We verify by reading the metadata pi produces and + // checking it has exactly the four fields AgentSessionMeta declares. + const { metadata } = await createPaneSession("/tmp/shape-check"); + expect(typeof metadata.id).toBe("string"); + expect(typeof metadata.createdAt).toBe("string"); + expect(typeof metadata.cwd).toBe("string"); + expect(typeof metadata.path).toBe("string"); + }); +}); + +describe("defaultSessionsDir", () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("uses WAVETERM_CONFIG_HOME when set", () => { + process.env.WAVETERM_CONFIG_HOME = "/tmp/probe-config"; + delete process.env.XDG_CONFIG_HOME; + delete process.env.WAVETERM_DEV; + expect(defaultSessionsDir()).toBe(path.join("/tmp/probe-config", "sessions")); + }); + + it("falls back to crest-dev when WAVETERM_DEV is set", () => { + delete process.env.WAVETERM_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = "/tmp/xdg"; + process.env.WAVETERM_DEV = "1"; + expect(defaultSessionsDir()).toBe(path.join("/tmp/xdg", "crest-dev", "sessions")); + }); + + it("falls back to crest in prod (no WAVETERM_DEV)", () => { + delete process.env.WAVETERM_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = "/tmp/xdg"; + delete process.env.WAVETERM_DEV; + expect(defaultSessionsDir()).toBe(path.join("/tmp/xdg", "crest", "sessions")); + }); +}); + +describe("buildSystemPrompt", () => { + it("renders cwd as the minimum context", () => { + const out = buildSystemPrompt({ cwd: "/Users/me/project" }); + expect(out).toContain("- cwd: /Users/me/project"); + expect(out).toContain("Pane context"); + }); + + it("includes git branch when present", () => { + const out = buildSystemPrompt({ cwd: "/x", gitBranch: "main" }); + expect(out).toContain("- git branch: main"); + }); + + it("skips connection line when local", () => { + const out = buildSystemPrompt({ cwd: "/x", connection: "local" }); + expect(out).not.toContain("connection"); + }); + + it("includes connection line for remote hosts", () => { + const out = buildSystemPrompt({ cwd: "/x", connection: "user@host" }); + expect(out).toContain("- connection: user@host"); + }); + + it("includes recent commands capped to 5", () => { + const cmds = Array.from({ length: 12 }, (_, i) => `cmd-${i}`); + const out = buildSystemPrompt({ cwd: "/x", recentCmds: cmds }); + expect(out).toContain("cmd-7"); // 12-5=7 is the first kept + expect(out).toContain("cmd-11"); + expect(out).not.toContain("cmd-6"); + }); + + it("omits the Recent commands section when no cmds provided", () => { + const out = buildSystemPrompt({ cwd: "/x" }); + expect(out).not.toContain("Recent commands"); + }); +}); diff --git a/emain/agent/sessions.ts b/emain/agent/sessions.ts new file mode 100644 index 000000000..c42182a4a --- /dev/null +++ b/emain/agent/sessions.ts @@ -0,0 +1,108 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +// +// sessions.ts — crest-side accessor for pi's JsonlSessionRepo. See +// docs/agent-runtime-architecture.md §4 for the storage model and §7.3 +// for why we use pi's repo directly instead of a custom store. +// +// Sessions live under {WAVETERM_CONFIG_HOME or ~/.config/crest{-dev}}/ +// sessions/{encodedCwd}/{timestamp}_{id}.jsonl — pi's cwd-grouped +// layout (matches pi-coding-agent / Aider / Claude Code: project = +// conversation context). One repo for the whole process; the env it +// holds is FS-only (no execution), so its cwd is irrelevant. Per-pane +// AgentHarness instances build their own NodeExecutionEnv with the +// pane's actual cwd when tool execution is needed. + +import * as os from "node:os"; +import * as path from "node:path"; + +import { JsonlSessionRepo } from "./harness/session/jsonl-repo"; +import type { JsonlSessionMetadata, Session } from "./harness/types"; +import { NodeExecutionEnv } from "./node"; + +// AgentSessionMeta from Go-generated TS types (frontend/types/gotypes.d.ts) +// is structurally a subset of pi's JsonlSessionMetadata. Field names +// match (Y1 camelCase decision, doc §7.2) so round-trip is identity. + +let _repo: JsonlSessionRepo | undefined; + +/** + * Resolve the sessions root directory mirroring the Go side's + * GetWaveConfigDir logic (pkg/wavebase/wavebase.go:130). Sessions + * sit beside ai.json under the same config home so all of crest's + * per-user AI state is one tree. + */ +export function defaultSessionsDir(): string { + const override = process.env.WAVETERM_CONFIG_HOME; + if (override) return path.join(override, "sessions"); + const isDev = process.env.WAVETERM_DEV === "1"; + const dirName = isDev ? "crest-dev" : "crest"; + const xdg = process.env.XDG_CONFIG_HOME; + const root = xdg ? path.join(xdg, dirName) : path.join(os.homedir(), ".config", dirName); + return path.join(root, "sessions"); +} + +/** + * Process-wide JsonlSessionRepo. Lazily constructed against + * defaultSessionsDir(). Tests can substitute via _setSessionsRepoForTests. + */ +export function getSessionsRepo(): JsonlSessionRepo { + if (!_repo) { + // The env attached to the repo is used only by JsonlSessionRepo + // for filesystem bookkeeping (joinPath/createDir/listDir/...); + // its cwd never matters because we pass absolute paths through + // the repo API. process.cwd() is a benign default. + const env = new NodeExecutionEnv({ cwd: process.cwd() }); + _repo = new JsonlSessionRepo({ fs: env, sessionsRoot: defaultSessionsDir() }); + } + return _repo; +} + +/** Test-only escape hatch: swap in a custom repo (e.g. pointed at a tmp dir). */ +export function _setSessionsRepoForTests(repo: JsonlSessionRepo | undefined): void { + _repo = repo; +} + +/** + * Mint a fresh session for a pane's cwd. Returns both the live pi Session + * (used to construct an AgentHarness) and the metadata object that goes + * into block.meta["agent:session"] so the renderer can re-open the same + * session across remounts / app restarts. + */ +export async function createPaneSession(cwd: string): Promise<{ + session: Session<JsonlSessionMetadata>; + metadata: JsonlSessionMetadata; +}> { + const repo = getSessionsRepo(); + const session = await repo.create({ cwd }); + const metadata = await session.getMetadata(); + return { session, metadata }; +} + +/** + * Re-open an existing session given its persisted metadata. Throws + * SessionError("not_found") when the JSONL file vanished (project + * moved, user deleted, etc.) — caller decides whether to fall back to + * minting a new session or surface a "this conversation is gone" UX. + * + * Accepts any structural subset of JsonlSessionMetadata so the + * AgentSessionMeta shape produced by Go round-trips without + * translation (doc §5.1). + */ +export async function openPaneSession( + metadata: JsonlSessionMetadata, +): Promise<Session<JsonlSessionMetadata>> { + return getSessionsRepo().open(metadata); +} + +/** + * List recent sessions for a cwd, newest first (sorted by pi's repo). + * Used by the "you have past conversations in this project" banner + * (doc §6.1). Empty array when there are none — caller should hide + * the banner in that case rather than render an empty list. + */ +export async function listSessionsForCwd(cwd: string): Promise<JsonlSessionMetadata[]> { + return getSessionsRepo().list({ cwd }); +} + +export type { JsonlSessionMetadata, Session } from "./harness/types"; diff --git a/emain/agent/tools/_paths.ts b/emain/agent/tools/_paths.ts new file mode 100644 index 000000000..dd77cb162 --- /dev/null +++ b/emain/agent/tools/_paths.ts @@ -0,0 +1,30 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as os from "node:os"; +import * as path from "node:path"; + +/** + * Expand a leading ~ or ~/ to the user's home dir. Crest tools accept + * tilde-prefixed paths because they're natural for terminal users; + * the agent's tool execution should mirror that. + */ +export function expandHome(p: string): string { + if (p === "~") return os.homedir(); + if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2)); + return p; +} + +/** + * Resolve a path argument to absolute form, expanding ~ first. Throws + * if the result is not absolute — tools require absolute inputs to + * avoid ambiguity with the harness env's cwd (which is the pane cwd + * at construction time, not at call time). + */ +export function requireAbsolute(p: string, toolName: string): string { + const expanded = expandHome(p); + if (!path.isAbsolute(expanded)) { + throw new Error(`${toolName}: path must be absolute or ~-prefixed; got "${p}"`); + } + return expanded; +} diff --git a/emain/agent/tools/index.ts b/emain/agent/tools/index.ts new file mode 100644 index 000000000..272613083 --- /dev/null +++ b/emain/agent/tools/index.ts @@ -0,0 +1,47 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +// +// Tools registry. v1 ships a pure-Node baseline (read / write / +// edit / list / web / shell) sufficient for the agent to do real +// coding work. Tools that need wavesrv state via wshrpc +// (create_block, focus_block, get_scrollback, browser, ask_user_question, +// transfer_to_user, spawn_task, cmd_history, file_tracker, dangerous) +// are TODO — they were deferred by the autonomous-session handoff +// because they need design decisions about the renderer/wavesrv +// bridge. + +import type { AgentTool } from "../types"; +import { listDirTool } from "./list-dir"; +import { multiEditTool } from "./multi-edit"; +import { readFileTool } from "./read-file"; +import { shellExecTool } from "./shell-exec"; +import { webFetchTool } from "./web-fetch"; +import { writeFileTool } from "./write-file"; + +export { listDirTool } from "./list-dir"; +export { multiEditTool } from "./multi-edit"; +export { readFileTool } from "./read-file"; +export { shellExecTool } from "./shell-exec"; +export { webFetchTool } from "./web-fetch"; +export { writeFileTool } from "./write-file"; + +/** + * Default tools enabled for every pane. The IPC layer passes this + * (or a filtered subset) to buildPaneHarness. + */ +export function getDefaultTools(): AgentTool[] { + return [readFileTool, writeFileTool, multiEditTool, listDirTool, webFetchTool, shellExecTool]; +} + +/** + * Tool names defined in v1. Useful for buildPermissionsHook's + * allowedTools list when callers want to enable only a subset. + */ +export const DEFAULT_TOOL_NAMES = [ + "read_file", + "write_file", + "multi_edit", + "list_dir", + "web_fetch", + "shell_exec", +] as const; diff --git a/emain/agent/tools/list-dir.ts b/emain/agent/tools/list-dir.ts new file mode 100644 index 000000000..0ee425bed --- /dev/null +++ b/emain/agent/tools/list-dir.ts @@ -0,0 +1,77 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +// +// list_dir — directory listing. Returns file + subdirectory names with +// a short type marker, suitable for the LLM to scan for relevant files +// without an `ls -la` shell call. + +import { promises as fs } from "node:fs"; +import * as path from "node:path"; +import { Type, type Static } from "typebox"; + +import type { AgentTool } from "../types"; +import { requireAbsolute } from "./_paths"; + +const NAME = "list_dir"; +const DEFAULT_MAX_ENTRIES = 500; + +const ListDirSchema = Type.Object({ + path: Type.String({ description: "Absolute (or ~-prefixed) directory path." }), + maxEntries: Type.Optional( + Type.Number({ description: `Cap on returned entries. Defaults to ${DEFAULT_MAX_ENTRIES}.` }), + ), +}); + +export interface ListDirDetails { + path: string; + entriesReturned: number; + truncated: boolean; +} + +interface DirEntry { + name: string; + type: "dir" | "file" | "symlink" | "other"; +} + +export const listDirTool: AgentTool<typeof ListDirSchema, ListDirDetails> = { + name: NAME, + label: "List Directory", + description: + "List the immediate contents of a directory. Returns one entry per line as '<type> <name>', where type is dir | file | symlink | other.", + parameters: ListDirSchema, + executionMode: "parallel", + async execute(_toolCallId, params): Promise<{ + content: [{ type: "text"; text: string }]; + details: ListDirDetails; + }> { + const abs = requireAbsolute(params.path, NAME); + const cap = Math.max(1, params.maxEntries ?? DEFAULT_MAX_ENTRIES); + const dirents = await fs.readdir(abs, { withFileTypes: true }); + const sorted = dirents.sort((a, b) => a.name.localeCompare(b.name)); + const truncated = sorted.length > cap; + const entries: DirEntry[] = sorted.slice(0, cap).map((d) => { + let type: DirEntry["type"] = "other"; + if (d.isDirectory()) type = "dir"; + else if (d.isFile()) type = "file"; + else if (d.isSymbolicLink()) type = "symlink"; + return { name: d.name, type }; + }); + const lines = entries.map((e) => `${e.type.padEnd(7, " ")} ${e.name}`); + if (truncated) { + lines.push(`... ${sorted.length - cap} more entries truncated; bump maxEntries to see all.`); + } + return { + content: [{ type: "text", text: lines.join("\n") || `(empty: ${abs})` }], + details: { + path: abs, + entriesReturned: entries.length, + truncated, + }, + }; + }, +}; + +type _Static = Static<typeof ListDirSchema>; +// path-import marker — placate the unused-import check while we keep +// path available for future helpers (e.g. join base + entry name). +void path.sep; diff --git a/emain/agent/tools/multi-edit.ts b/emain/agent/tools/multi-edit.ts new file mode 100644 index 000000000..dd5b9d37b --- /dev/null +++ b/emain/agent/tools/multi-edit.ts @@ -0,0 +1,102 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +// +// multi_edit — apply a sequence of exact-string replacements to one +// file, atomically (all or nothing — if any edit's oldString isn't +// unique or isn't found, no writes happen). Mirrors crest's existing +// Go multi_edit semantics; safer than write_file for surgical changes +// because the LLM doesn't have to reproduce the entire file. + +import { promises as fs } from "node:fs"; +import { Type, type Static } from "typebox"; + +import type { AgentTool } from "../types"; +import { requireAbsolute } from "./_paths"; + +const NAME = "multi_edit"; + +const EditSchema = Type.Object({ + oldString: Type.String({ + description: + "Exact substring to replace. Must occur exactly once in the file (or replaceAll must be true).", + }), + newString: Type.String({ description: "Replacement substring." }), + replaceAll: Type.Optional( + Type.Boolean({ + description: "If true, replace every occurrence of oldString. Defaults to false.", + }), + ), +}); + +const MultiEditSchema = Type.Object({ + filename: Type.String({ description: "Absolute path (or ~-prefixed) to the file to edit." }), + edits: Type.Array(EditSchema, { + description: + "Ordered list of edits. Each is applied to the result of the previous edit. All must succeed; if any fails the file is left untouched.", + }), +}); + +export interface MultiEditDetails { + path: string; + editsApplied: number; + bytesBefore: number; + bytesAfter: number; +} + +export const multiEditTool: AgentTool<typeof MultiEditSchema, MultiEditDetails> = { + name: NAME, + label: "Edit File", + description: + "Apply one or more exact-string replacements to a file atomically. Each edit's oldString must be unique in the (intermediate) file unless replaceAll is true. Failures roll back the entire batch — no partial writes.", + parameters: MultiEditSchema, + async execute(_toolCallId, params): Promise<{ + content: [{ type: "text"; text: string }]; + details: MultiEditDetails; + }> { + const abs = requireAbsolute(params.filename, NAME); + const original = await fs.readFile(abs, "utf8"); + let working = original; + for (let i = 0; i < params.edits.length; i++) { + const edit = params.edits[i]; + if (edit.replaceAll) { + if (!working.includes(edit.oldString)) { + throw new Error( + `${NAME}: edit #${i + 1} oldString not found in file ${abs}`, + ); + } + working = working.split(edit.oldString).join(edit.newString); + continue; + } + const first = working.indexOf(edit.oldString); + if (first < 0) { + throw new Error( + `${NAME}: edit #${i + 1} oldString not found in file ${abs}`, + ); + } + const second = working.indexOf(edit.oldString, first + edit.oldString.length); + if (second >= 0) { + throw new Error( + `${NAME}: edit #${i + 1} oldString matches ${working.split(edit.oldString).length - 1} times in ${abs} (set replaceAll:true or pass a longer oldString)`, + ); + } + working = working.slice(0, first) + edit.newString + working.slice(first + edit.oldString.length); + } + await fs.writeFile(abs, working, "utf8"); + return { + content: [ + { + type: "text", + text: `Applied ${params.edits.length} edit(s) to ${abs}`, + }, + ], + details: { + path: abs, + editsApplied: params.edits.length, + bytesBefore: Buffer.byteLength(original, "utf8"), + bytesAfter: Buffer.byteLength(working, "utf8"), + }, + }; + }, +}; + +type _Static = Static<typeof MultiEditSchema>; diff --git a/emain/agent/tools/read-file.ts b/emain/agent/tools/read-file.ts new file mode 100644 index 000000000..cb0a00513 --- /dev/null +++ b/emain/agent/tools/read-file.ts @@ -0,0 +1,71 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +// +// read_file — read a UTF-8 text file from disk. Mirrors crest's +// previous Go read_text_file tool but as a pi AgentTool that runs in +// the Electron main process. + +import { promises as fs } from "node:fs"; +import { Type, type Static } from "typebox"; + +import type { AgentTool } from "../types"; +import { requireAbsolute } from "./_paths"; + +const DEFAULT_LINE_LIMIT = 2000; +const NAME = "read_file"; + +const ReadFileSchema = Type.Object({ + filename: Type.String({ + description: + "Absolute path to read (or ~-prefixed). Relative paths are rejected to avoid ambiguity with the pane's cwd.", + }), + offset: Type.Optional( + Type.Number({ + description: "1-indexed first line to read. Defaults to the start of the file.", + }), + ), + limit: Type.Optional( + Type.Number({ + description: `Maximum number of lines to read. Defaults to ${DEFAULT_LINE_LIMIT}.`, + }), + ), +}); + +export interface ReadFileDetails { + path: string; + bytesRead: number; + linesReturned: number; + truncated: boolean; +} + +export const readFileTool: AgentTool<typeof ReadFileSchema, ReadFileDetails> = { + name: NAME, + label: "Read File", + description: + "Read a UTF-8 text file. Use the offset/limit parameters when you only need a specific section — don't pull entire large files.", + parameters: ReadFileSchema, + executionMode: "parallel", + async execute(_toolCallId, params): Promise<{ + content: [{ type: "text"; text: string }]; + details: ReadFileDetails; + }> { + const abs = requireAbsolute(params.filename, NAME); + const raw = await fs.readFile(abs, "utf8"); + const allLines = raw.split("\n"); + const offset = Math.max(0, (params.offset ?? 1) - 1); + const limit = Math.max(1, params.limit ?? DEFAULT_LINE_LIMIT); + const slice = allLines.slice(offset, offset + limit); + const truncated = offset + limit < allLines.length || offset > 0; + return { + content: [{ type: "text", text: slice.join("\n") }], + details: { + path: abs, + bytesRead: Buffer.byteLength(raw, "utf8"), + linesReturned: slice.length, + truncated, + }, + }; + }, +}; + +type _Static = Static<typeof ReadFileSchema>; diff --git a/emain/agent/tools/shell-exec.ts b/emain/agent/tools/shell-exec.ts new file mode 100644 index 000000000..480303c73 --- /dev/null +++ b/emain/agent/tools/shell-exec.ts @@ -0,0 +1,153 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +// +// shell_exec — run a shell command, return stdout/stderr/exitCode. +// Runs in the pane's cwd via the AgentHarness's env (caller passes a +// resolved cwd). v1 is "headless" — output is captured and returned +// to the LLM, not displayed in a terminal block. A future block-tied +// variant (the dropped Go headless_shell_exec.go) can be added when +// the renderer / wavesrv-bridged tools land. +// +// Caveats: +// - Output is capped at OUTPUT_CAP_BYTES per stream to keep the +// model's context budget reasonable. Long-running tools that +// stream a lot should be wrapped (e.g. `cmd | tail -n 200`). +// - Aborts via SIGTERM (then SIGKILL after a grace) on signal abort. + +import { spawn } from "node:child_process"; +import { Type, type Static } from "typebox"; + +import type { AgentTool } from "../types"; + +const NAME = "shell_exec"; +const DEFAULT_TIMEOUT_MS = 60_000; +const OUTPUT_CAP_BYTES = 64 * 1024; // per stream — stdout and stderr each +const KILL_GRACE_MS = 2_000; + +const ShellExecSchema = Type.Object({ + command: Type.String({ + description: + "Shell command line to execute. Runs through /bin/sh -c so pipes / redirects / globs work.", + }), + cwd: Type.Optional( + Type.String({ + description: + "Optional working directory. Defaults to the pane's cwd (the harness's env.cwd).", + }), + ), + timeoutMs: Type.Optional( + Type.Number({ description: `Per-command timeout. Defaults to ${DEFAULT_TIMEOUT_MS}ms.` }), + ), +}); + +export interface ShellExecDetails { + command: string; + cwd: string; + exitCode: number | null; + signal: NodeJS.Signals | null; + stdoutBytes: number; + stderrBytes: number; + stdoutTruncated: boolean; + stderrTruncated: boolean; + timedOut: boolean; +} + +function appendCapped( + chunks: Buffer[], + totalRef: { bytes: number }, + next: Buffer, +): boolean { + // returns true if append truncated (cap reached) + const remaining = OUTPUT_CAP_BYTES - totalRef.bytes; + if (remaining <= 0) return true; + if (next.byteLength <= remaining) { + chunks.push(next); + totalRef.bytes += next.byteLength; + return false; + } + chunks.push(next.subarray(0, remaining)); + totalRef.bytes += remaining; + return true; +} + +export const shellExecTool: AgentTool<typeof ShellExecSchema, ShellExecDetails> = { + name: NAME, + label: "Run Shell", + description: + "Run a shell command. Output (stdout + stderr) and exit code are returned to the model. Long-running commands should pipe through head/tail/grep to fit the response budget.", + parameters: ShellExecSchema, + executionMode: "sequential", // sequential to avoid concurrent state-mutating commands + async execute(_toolCallId, params, signal): Promise<{ + content: [{ type: "text"; text: string }]; + details: ShellExecDetails; + }> { + const cwd = params.cwd ?? process.cwd(); + const timeoutMs = Math.max(1_000, params.timeoutMs ?? DEFAULT_TIMEOUT_MS); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + const stdoutRef = { bytes: 0 }; + const stderrRef = { bytes: 0 }; + let stdoutTruncated = false; + let stderrTruncated = false; + let timedOut = false; + + return new Promise((resolve, reject) => { + const child = spawn("/bin/sh", ["-c", params.command], { cwd }); + const timeoutTimer = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + setTimeout(() => { + if (!child.killed) child.kill("SIGKILL"); + }, KILL_GRACE_MS); + }, timeoutMs); + + const onAbort = () => { + child.kill("SIGTERM"); + setTimeout(() => { + if (!child.killed) child.kill("SIGKILL"); + }, KILL_GRACE_MS); + }; + signal?.addEventListener("abort", onAbort); + + child.stdout?.on("data", (chunk: Buffer) => { + if (appendCapped(stdoutChunks, stdoutRef, chunk)) stdoutTruncated = true; + }); + child.stderr?.on("data", (chunk: Buffer) => { + if (appendCapped(stderrChunks, stderrRef, chunk)) stderrTruncated = true; + }); + child.on("error", (err) => { + clearTimeout(timeoutTimer); + signal?.removeEventListener("abort", onAbort); + reject(err); + }); + child.on("close", (exitCode, exitSignal) => { + clearTimeout(timeoutTimer); + signal?.removeEventListener("abort", onAbort); + const stdout = Buffer.concat(stdoutChunks).toString("utf8"); + const stderr = Buffer.concat(stderrChunks).toString("utf8"); + const lines: string[] = []; + lines.push(`$ ${params.command}`); + lines.push(`(exit ${exitCode ?? "?"}${exitSignal ? `, signal ${exitSignal}` : ""}${timedOut ? ", TIMED OUT" : ""})`); + if (stdout) lines.push("--- stdout ---", stdout + (stdoutTruncated ? "\n[stdout truncated]" : "")); + if (stderr) lines.push("--- stderr ---", stderr + (stderrTruncated ? "\n[stderr truncated]" : "")); + if (!stdout && !stderr) lines.push("(no output)"); + resolve({ + content: [{ type: "text", text: lines.join("\n") }], + details: { + command: params.command, + cwd, + exitCode, + signal: exitSignal, + stdoutBytes: stdoutRef.bytes, + stderrBytes: stderrRef.bytes, + stdoutTruncated, + stderrTruncated, + timedOut, + }, + }); + }); + }); + }, +}; + +type _Static = Static<typeof ShellExecSchema>; diff --git a/emain/agent/tools/tools.test.ts b/emain/agent/tools/tools.test.ts new file mode 100644 index 000000000..960eb7080 --- /dev/null +++ b/emain/agent/tools/tools.test.ts @@ -0,0 +1,298 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +// +// Unit tests for the v1 tool baseline. web_fetch hits a real loopback +// HTTP server to keep the test deterministic and offline. + +import { createServer, type Server } from "node:http"; +import { promises as fs } from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { DEFAULT_TOOL_NAMES, getDefaultTools } from "./index"; +import { listDirTool } from "./list-dir"; +import { multiEditTool } from "./multi-edit"; +import { readFileTool } from "./read-file"; +import { shellExecTool } from "./shell-exec"; +import { webFetchTool } from "./web-fetch"; +import { writeFileTool } from "./write-file"; +import { expandHome, requireAbsolute } from "./_paths"; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "crest-tools-test-")); +}); + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe("_paths", () => { + it("expandHome leaves absolute paths alone", () => { + expect(expandHome("/tmp/x")).toBe("/tmp/x"); + expect(expandHome("/")).toBe("/"); + }); + + it("expandHome replaces ~ and ~/...", () => { + expect(expandHome("~")).toBe(os.homedir()); + expect(expandHome("~/foo/bar")).toBe(path.join(os.homedir(), "foo/bar")); + }); + + it("requireAbsolute rejects relative paths", () => { + expect(() => requireAbsolute("relative/x", "t")).toThrow(/absolute/); + }); + + it("requireAbsolute accepts ~-prefixed paths after expansion", () => { + expect(requireAbsolute("~/x", "t")).toBe(path.join(os.homedir(), "x")); + }); +}); + +describe("read_file", () => { + it("reads a small file end-to-end", async () => { + const file = path.join(tmpDir, "hello.txt"); + await fs.writeFile(file, "line 1\nline 2\nline 3\n"); + const result = await readFileTool.execute("tc-1", { filename: file }); + expect((result.content[0] as { type: "text"; text: string }).text).toContain("line 1"); + expect((result.content[0] as { type: "text"; text: string }).text).toContain("line 3"); + expect(result.details.linesReturned).toBeGreaterThan(0); + }); + + it("respects offset + limit", async () => { + const file = path.join(tmpDir, "many.txt"); + await fs.writeFile(file, Array.from({ length: 100 }, (_, i) => `line ${i + 1}`).join("\n")); + const result = await readFileTool.execute("tc-1", { filename: file, offset: 50, limit: 5 }); + expect((result.content[0] as { type: "text"; text: string }).text).toBe("line 50\nline 51\nline 52\nline 53\nline 54"); + expect(result.details.linesReturned).toBe(5); + expect(result.details.truncated).toBe(true); + }); + + it("rejects non-absolute paths", async () => { + await expect(readFileTool.execute("tc-1", { filename: "relative.txt" })).rejects.toThrow( + /absolute/, + ); + }); +}); + +describe("write_file", () => { + it("creates a new file in an existing dir", async () => { + const file = path.join(tmpDir, "new.txt"); + const result = await writeFileTool.execute("tc-1", { filename: file, content: "hello" }); + expect(await fs.readFile(file, "utf8")).toBe("hello"); + expect(result.details.created).toBe(true); + expect(result.details.bytesWritten).toBe(5); + }); + + it("creates parent directories on demand", async () => { + const file = path.join(tmpDir, "nested/deep/path/x.txt"); + await writeFileTool.execute("tc-1", { filename: file, content: "y" }); + expect(await fs.readFile(file, "utf8")).toBe("y"); + }); + + it("overwrites existing content", async () => { + const file = path.join(tmpDir, "ex.txt"); + await fs.writeFile(file, "before"); + const result = await writeFileTool.execute("tc-1", { filename: file, content: "after" }); + expect(await fs.readFile(file, "utf8")).toBe("after"); + expect(result.details.created).toBe(false); + }); +}); + +describe("multi_edit", () => { + it("applies a single unique replacement", async () => { + const file = path.join(tmpDir, "code.ts"); + await fs.writeFile(file, "const x = 1;\nconst y = 2;\n"); + await multiEditTool.execute("tc-1", { + filename: file, + edits: [{ oldString: "const x = 1;", newString: "const x = 42;" }], + }); + expect(await fs.readFile(file, "utf8")).toBe("const x = 42;\nconst y = 2;\n"); + }); + + it("applies edits in order, each seeing the previous result", async () => { + const file = path.join(tmpDir, "chain.txt"); + await fs.writeFile(file, "A B C"); + await multiEditTool.execute("tc-1", { + filename: file, + edits: [ + { oldString: "A", newString: "X" }, + { oldString: "X B", newString: "Y" }, // depends on first edit's result + ], + }); + expect(await fs.readFile(file, "utf8")).toBe("Y C"); + }); + + it("throws when oldString isn't unique and replaceAll is false", async () => { + const file = path.join(tmpDir, "dup.txt"); + await fs.writeFile(file, "x\nx\nx\n"); + await expect( + multiEditTool.execute("tc-1", { + filename: file, + edits: [{ oldString: "x", newString: "y" }], + }), + ).rejects.toThrow(/matches/); + }); + + it("handles replaceAll across multiple occurrences", async () => { + const file = path.join(tmpDir, "rep.txt"); + await fs.writeFile(file, "x\nx\nx\n"); + await multiEditTool.execute("tc-1", { + filename: file, + edits: [{ oldString: "x", newString: "y", replaceAll: true }], + }); + expect(await fs.readFile(file, "utf8")).toBe("y\ny\ny\n"); + }); + + it("does not write anything when any edit fails", async () => { + const file = path.join(tmpDir, "atomic.txt"); + await fs.writeFile(file, "alpha\nbeta\n"); + const original = await fs.readFile(file, "utf8"); + await expect( + multiEditTool.execute("tc-1", { + filename: file, + edits: [ + { oldString: "alpha", newString: "ALPHA" }, + { oldString: "missing", newString: "X" }, + ], + }), + ).rejects.toThrow(/not found/); + expect(await fs.readFile(file, "utf8")).toBe(original); + }); +}); + +describe("list_dir", () => { + it("lists entries with type markers", async () => { + await fs.writeFile(path.join(tmpDir, "a.txt"), "x"); + await fs.mkdir(path.join(tmpDir, "subdir")); + const result = await listDirTool.execute("tc-1", { path: tmpDir }); + expect((result.content[0] as { type: "text"; text: string }).text).toContain("file a.txt"); + expect((result.content[0] as { type: "text"; text: string }).text).toContain("dir subdir"); + }); + + it("respects maxEntries cap and signals truncation", async () => { + for (let i = 0; i < 10; i++) await fs.writeFile(path.join(tmpDir, `f${i}.txt`), ""); + const result = await listDirTool.execute("tc-1", { path: tmpDir, maxEntries: 3 }); + expect(result.details.entriesReturned).toBe(3); + expect(result.details.truncated).toBe(true); + expect((result.content[0] as { type: "text"; text: string }).text).toContain("more entries truncated"); + }); + + it("returns a placeholder for an empty directory", async () => { + const result = await listDirTool.execute("tc-1", { path: tmpDir }); + expect((result.content[0] as { type: "text"; text: string }).text).toContain("(empty"); + }); +}); + +describe("shell_exec", () => { + it("captures stdout and exit code from a simple command", async () => { + const result = await shellExecTool.execute("tc-1", { command: "echo hi" }); + expect((result.content[0] as { type: "text"; text: string }).text).toContain("hi"); + expect(result.details.exitCode).toBe(0); + }); + + it("captures stderr and non-zero exit", async () => { + const result = await shellExecTool.execute("tc-1", { + command: "echo oops 1>&2; exit 3", + }); + expect((result.content[0] as { type: "text"; text: string }).text).toContain("oops"); + expect(result.details.exitCode).toBe(3); + }); + + it("times out long-running commands", async () => { + const result = await shellExecTool.execute("tc-1", { + command: "sleep 5", + timeoutMs: 200, + }); + expect(result.details.timedOut).toBe(true); + // shell-exec floors timeoutMs at 1000ms and allows a 2000ms + // SIGKILL grace after SIGTERM, so worst case is ~3s + spawn + // overhead. The generous vitest budget keeps this from flaking + // on loaded CI runners (it's ~1s locally). + }, 20_000); + + it("respects the cwd parameter", async () => { + const result = await shellExecTool.execute("tc-1", { + command: "pwd", + cwd: tmpDir, + }); + expect((result.content[0] as { type: "text"; text: string }).text).toContain(tmpDir); + }); +}); + +describe("web_fetch", () => { + let server: Server; + let baseUrl: string; + + beforeEach(async () => { + await new Promise<void>((resolve) => { + server = createServer((req, res) => { + if (req.url === "/ok") { + res.writeHead(200, { "content-type": "text/plain" }); + res.end("hello from server"); + } else if (req.url === "/json") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ greeting: "hi" })); + } else if (req.url === "/big") { + res.writeHead(200, { "content-type": "text/plain" }); + res.end("x".repeat(2_000_000)); + } else { + res.writeHead(404); + res.end("nope"); + } + }).listen(0, "127.0.0.1", () => resolve()); + }); + const addr = server.address(); + if (typeof addr === "object" && addr) baseUrl = `http://127.0.0.1:${addr.port}`; + }); + + afterEach(() => { + server.close(); + }); + + it("returns body text on 200", async () => { + const result = await webFetchTool.execute("tc-1", { url: `${baseUrl}/ok` }); + expect((result.content[0] as { type: "text"; text: string }).text).toContain("hello from server"); + expect(result.details.status).toBe(200); + }); + + it("includes content-type in metadata", async () => { + const result = await webFetchTool.execute("tc-1", { url: `${baseUrl}/json` }); + expect(result.details.contentType).toContain("application/json"); + }); + + it("rejects non-http(s) URLs", async () => { + await expect(webFetchTool.execute("tc-1", { url: "file:///etc/passwd" })).rejects.toThrow( + /http/, + ); + }); + + it("truncates large bodies", async () => { + const result = await webFetchTool.execute("tc-1", { url: `${baseUrl}/big` }); + expect(result.details.truncated).toBe(true); + expect(result.details.bytesReturned).toBeLessThanOrEqual(1_000_000); + }); +}); + +describe("tools registry", () => { + it("getDefaultTools returns all 6 v1 tools", () => { + const tools = getDefaultTools(); + expect(tools.map((t) => t.name).sort()).toEqual( + ["list_dir", "multi_edit", "read_file", "shell_exec", "web_fetch", "write_file"].sort(), + ); + }); + + it("DEFAULT_TOOL_NAMES matches getDefaultTools", () => { + const tools = getDefaultTools(); + expect(new Set(tools.map((t) => t.name))).toEqual(new Set(DEFAULT_TOOL_NAMES)); + }); + + it("every default tool declares name, label, description, parameters", () => { + for (const tool of getDefaultTools()) { + expect(tool.name).toBeTruthy(); + expect(tool.label).toBeTruthy(); + expect(tool.description).toBeTruthy(); + expect(tool.parameters).toBeDefined(); + } + }); +}); diff --git a/emain/agent/tools/web-fetch.ts b/emain/agent/tools/web-fetch.ts new file mode 100644 index 000000000..ca5e92299 --- /dev/null +++ b/emain/agent/tools/web-fetch.ts @@ -0,0 +1,87 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +// +// web_fetch — GET a URL and return its body as text. Uses Node's +// built-in fetch (Node 22+). No browser, no JS execution — just HTTP. +// For pages that need rendering, the future browser tool will own +// that path; web_fetch stays minimal. + +import { Type, type Static } from "typebox"; + +import type { AgentTool } from "../types"; + +const NAME = "web_fetch"; +const DEFAULT_TIMEOUT_MS = 30_000; +const MAX_BODY_BYTES = 1_000_000; // 1 MB — keep LLM prompt budget sane. + +const WebFetchSchema = Type.Object({ + url: Type.String({ description: "Absolute HTTP/HTTPS URL to GET." }), + timeoutMs: Type.Optional( + Type.Number({ description: `Network timeout. Defaults to ${DEFAULT_TIMEOUT_MS}ms.` }), + ), +}); + +export interface WebFetchDetails { + url: string; + status: number; + contentType: string; + bytesReturned: number; + truncated: boolean; +} + +export const webFetchTool: AgentTool<typeof WebFetchSchema, WebFetchDetails> = { + name: NAME, + label: "Fetch URL", + description: + "HTTP GET a URL and return the body as text. Use for fetching docs, API responses, or small assets — not for full HTML page rendering.", + parameters: WebFetchSchema, + executionMode: "parallel", + async execute(_toolCallId, params, signal): Promise<{ + content: [{ type: "text"; text: string }]; + details: WebFetchDetails; + }> { + const url = params.url; + if (!/^https?:\/\//.test(url)) { + throw new Error(`${NAME}: url must start with http:// or https://; got "${url}"`); + } + const timeoutMs = Math.max(1_000, params.timeoutMs ?? DEFAULT_TIMEOUT_MS); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + // Cascade the caller-supplied abort signal so the agent can + // cancel an in-flight fetch when the user hits abort. + const onAbort = () => controller.abort(); + signal?.addEventListener("abort", onAbort); + try { + const response = await fetch(url, { signal: controller.signal }); + const buf = await response.arrayBuffer(); + const bytes = buf.byteLength; + const truncated = bytes > MAX_BODY_BYTES; + const sliceBytes = truncated ? buf.slice(0, MAX_BODY_BYTES) : buf; + const text = new TextDecoder("utf-8", { fatal: false }).decode(sliceBytes); + const contentType = response.headers.get("content-type") ?? ""; + const note = truncated + ? `\n\n[truncated — body was ${bytes} bytes, only the first ${MAX_BODY_BYTES} are shown]` + : ""; + return { + content: [ + { + type: "text", + text: `HTTP ${response.status} ${response.statusText}\nContent-Type: ${contentType}\n\n${text}${note}`, + }, + ], + details: { + url, + status: response.status, + contentType, + bytesReturned: Math.min(bytes, MAX_BODY_BYTES), + truncated, + }, + }; + } finally { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + } + }, +}; + +type _Static = Static<typeof WebFetchSchema>; diff --git a/emain/agent/tools/write-file.ts b/emain/agent/tools/write-file.ts new file mode 100644 index 000000000..00215c212 --- /dev/null +++ b/emain/agent/tools/write-file.ts @@ -0,0 +1,58 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +// +// write_file — create or overwrite a UTF-8 text file. mkdir -p style: +// parent directories are auto-created so the tool can land a new file +// in a deep path without a separate mkdir call. + +import { promises as fs } from "node:fs"; +import * as path from "node:path"; +import { Type, type Static } from "typebox"; + +import type { AgentTool } from "../types"; +import { requireAbsolute } from "./_paths"; + +const NAME = "write_file"; + +const WriteFileSchema = Type.Object({ + filename: Type.String({ + description: "Absolute path (or ~-prefixed). Parent directories are created on demand.", + }), + content: Type.String({ + description: "Full file contents to write. Existing content is overwritten.", + }), +}); + +export interface WriteFileDetails { + path: string; + bytesWritten: number; + created: boolean; +} + +export const writeFileTool: AgentTool<typeof WriteFileSchema, WriteFileDetails> = { + name: NAME, + label: "Write File", + description: + "Create or overwrite a file. Prefer multi_edit for targeted changes to existing files; use write_file for wholly new files or when replacing the entire content is intended.", + parameters: WriteFileSchema, + async execute(_toolCallId, params): Promise<{ + content: [{ type: "text"; text: string }]; + details: WriteFileDetails; + }> { + const abs = requireAbsolute(params.filename, NAME); + const existed = await fs + .stat(abs) + .then(() => true) + .catch(() => false); + await fs.mkdir(path.dirname(abs), { recursive: true }); + await fs.writeFile(abs, params.content, "utf8"); + const bytes = Buffer.byteLength(params.content, "utf8"); + const verb = existed ? "Overwrote" : "Created"; + return { + content: [{ type: "text", text: `${verb} ${abs} (${bytes} bytes)` }], + details: { path: abs, bytesWritten: bytes, created: !existed }, + }; + }, +}; + +type _Static = Static<typeof WriteFileSchema>; diff --git a/emain/agent/types.ts b/emain/agent/types.ts new file mode 100644 index 000000000..adc11850b --- /dev/null +++ b/emain/agent/types.ts @@ -0,0 +1,418 @@ +import type { + AssistantMessage, + AssistantMessageEvent, + ImageContent, + Message, + Model, + SimpleStreamOptions, + streamSimple, + TextContent, + Tool, + ToolResultMessage, +} from "../ai"; +import type { Static, TSchema } from "typebox"; + +/** + * Stream function used by the agent loop. + * + * Contract: + * - Must not throw or return a rejected promise for request/model/runtime failures. + * - Must return an AssistantMessageEventStream. + * - Failures must be encoded in the returned stream via protocol events and a + * final AssistantMessage with stopReason "error" or "aborted" and errorMessage. + */ +export type StreamFn = ( + ...args: Parameters<typeof streamSimple> +) => ReturnType<typeof streamSimple> | Promise<ReturnType<typeof streamSimple>>; + +/** + * Configuration for how tool calls from a single assistant message are executed. + * + * - "sequential": each tool call is prepared, executed, and finalized before the next one starts. + * - "parallel": tool calls are prepared sequentially, then allowed tools execute concurrently. + * `tool_execution_end` is emitted in tool completion order after each tool is finalized, + * while tool-result message artifacts are emitted later in assistant source order. + */ +export type ToolExecutionMode = "sequential" | "parallel"; + +/** + * Controls how many queued user messages are injected when the agent loop reaches a queue drain point. + * + * - "all": drain and inject every queued message at that point. + * - "one-at-a-time": drain and inject only the oldest queued message, leaving the rest queued for later drain points. + */ +export type QueueMode = "all" | "one-at-a-time"; + +/** A single tool call content block emitted by an assistant message. */ +export type AgentToolCall = Extract<AssistantMessage["content"][number], { type: "toolCall" }>; + +/** + * Result returned from `beforeToolCall`. + * + * Returning `{ block: true }` prevents the tool from executing. The loop emits an error tool result instead. + * `reason` becomes the text shown in that error result. If omitted, a default blocked message is used. + */ +export interface BeforeToolCallResult { + block?: boolean; + reason?: string; +} + +/** + * Partial override returned from `afterToolCall`. + * + * Merge semantics are field-by-field: + * - `content`: if provided, replaces the tool result content array in full + * - `details`: if provided, replaces the tool result details value in full + * - `isError`: if provided, replaces the tool result error flag + * - `terminate`: if provided, replaces the early-termination hint + * + * Omitted fields keep the original executed tool result values. + * There is no deep merge for `content` or `details`. + */ +export interface AfterToolCallResult { + content?: (TextContent | ImageContent)[]; + details?: unknown; + isError?: boolean; + /** + * Hint that the agent should stop after the current tool batch. + * Early termination only happens when every finalized tool result in the batch sets this to true. + */ + terminate?: boolean; +} + +/** Context passed to `beforeToolCall`. */ +export interface BeforeToolCallContext { + /** The assistant message that requested the tool call. */ + assistantMessage: AssistantMessage; + /** The raw tool call block from `assistantMessage.content`. */ + toolCall: AgentToolCall; + /** Validated tool arguments for the target tool schema. */ + args: unknown; + /** Current agent context at the time the tool call is prepared. */ + context: AgentContext; +} + +/** Context passed to `afterToolCall`. */ +export interface AfterToolCallContext { + /** The assistant message that requested the tool call. */ + assistantMessage: AssistantMessage; + /** The raw tool call block from `assistantMessage.content`. */ + toolCall: AgentToolCall; + /** Validated tool arguments for the target tool schema. */ + args: unknown; + /** The executed tool result before any `afterToolCall` overrides are applied. */ + result: AgentToolResult<any>; + /** Whether the executed tool result is currently treated as an error. */ + isError: boolean; + /** Current agent context at the time the tool call is finalized. */ + context: AgentContext; +} + +/** Context passed to `shouldStopAfterTurn`. */ +export interface ShouldStopAfterTurnContext { + /** The assistant message that completed the turn. */ + message: AssistantMessage; + /** Tool result messages passed to the preceding `turn_end` event. */ + toolResults: ToolResultMessage[]; + /** Current agent context after the turn's assistant message and tool results have been appended. */ + context: AgentContext; + /** Messages that this loop invocation will return if it exits at this point. Prompt runs include the initial prompt messages; continuation runs do not include pre-existing context messages. */ + newMessages: AgentMessage[]; +} + +/** Replacement runtime state used by the agent loop before starting another provider request. */ +export interface AgentLoopTurnUpdate { + /** Context for the next provider request. */ + context?: AgentContext; + /** Model for the next provider request. */ + model?: Model<any>; + /** Thinking level for the next provider request. */ + thinkingLevel?: ThinkingLevel; +} + +export interface PrepareNextTurnContext extends ShouldStopAfterTurnContext {} + +export interface AgentLoopConfig extends SimpleStreamOptions { + model: Model<any>; + + /** + * Converts AgentMessage[] to LLM-compatible Message[] before each LLM call. + * + * Each AgentMessage must be converted to a UserMessage, AssistantMessage, or ToolResultMessage + * that the LLM can understand. AgentMessages that cannot be converted (e.g., UI-only notifications, + * status messages) should be filtered out. + * + * Contract: must not throw or reject. Return a safe fallback value instead. + * Throwing interrupts the low-level agent loop without producing a normal event sequence. + * + * @example + * ```typescript + * convertToLlm: (messages) => messages.flatMap(m => { + * if (m.role === "custom") { + * // Convert custom message to user message + * return [{ role: "user", content: m.content, timestamp: m.timestamp }]; + * } + * if (m.role === "notification") { + * // Filter out UI-only messages + * return []; + * } + * // Pass through standard LLM messages + * return [m]; + * }) + * ``` + */ + convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>; + + /** + * Optional transform applied to the context before `convertToLlm`. + * + * Use this for operations that work at the AgentMessage level: + * - Context window management (pruning old messages) + * - Injecting context from external sources + * + * Contract: must not throw or reject. Return the original messages or another + * safe fallback value instead. + * + * @example + * ```typescript + * transformContext: async (messages) => { + * if (estimateTokens(messages) > MAX_TOKENS) { + * return pruneOldMessages(messages); + * } + * return messages; + * } + * ``` + */ + transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>; + + /** + * Resolves an API key dynamically for each LLM call. + * + * Useful for short-lived OAuth tokens (e.g., GitHub Copilot) that may expire + * during long-running tool execution phases. + * + * Contract: must not throw or reject. Return undefined when no key is available. + */ + getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined; + + /** + * Called after each turn fully completes and `turn_end` has been emitted. + * + * If it returns true, the loop emits `agent_end` and exits before polling steering or follow-up queues, + * without starting another LLM call. The current assistant response and any tool executions finish normally. + * + * Use this to request a graceful stop after the current turn, e.g. before context gets too full. + * + * Contract: must not throw or reject. Throwing interrupts the low-level agent loop without producing a normal event sequence. + */ + shouldStopAfterTurn?: (context: ShouldStopAfterTurnContext) => boolean | Promise<boolean>; + + /** + * Called after `turn_end` and before the loop decides whether another provider request should start. + * Return replacement context/model/thinking state to affect the next turn in this run. + * Return undefined to keep using the current context/config. + */ + prepareNextTurn?: ( + context: PrepareNextTurnContext, + ) => AgentLoopTurnUpdate | undefined | Promise<AgentLoopTurnUpdate | undefined>; + + /** + * Returns steering messages to inject into the conversation mid-run. + * + * Called after the current assistant turn finishes executing its tool calls, unless `shouldStopAfterTurn` exits first. + * If messages are returned, they are added to the context before the next LLM call. + * Tool calls from the current assistant message are not skipped. + * + * Use this for "steering" the agent while it's working. + * + * Contract: must not throw or reject. Return [] when no steering messages are available. + */ + getSteeringMessages?: () => Promise<AgentMessage[]>; + + /** + * Returns follow-up messages to process after the agent would otherwise stop. + * + * Called when the agent has no more tool calls and no steering messages. + * If messages are returned, they're added to the context and the agent + * continues with another turn. + * + * Use this for follow-up messages that should wait until the agent finishes. + * + * Contract: must not throw or reject. Return [] when no follow-up messages are available. + */ + getFollowUpMessages?: () => Promise<AgentMessage[]>; + + /** + * Tool execution mode. + * - "sequential": execute tool calls one by one + * - "parallel": preflight tool calls sequentially, then execute allowed tools concurrently; + * emit `tool_execution_end` in tool completion order after each tool is finalized, + * then emit tool-result message artifacts later in assistant source order + * + * Default: "parallel" + */ + toolExecution?: ToolExecutionMode; + + /** + * Called before a tool is executed, after arguments have been validated. + * + * Return `{ block: true }` to prevent execution. The loop emits an error tool result instead. + * The hook receives the agent abort signal and is responsible for honoring it. + */ + beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined>; + + /** + * Called after a tool finishes executing, before `tool_execution_end` and tool-result message events are emitted. + * + * Return an `AfterToolCallResult` to override parts of the executed tool result: + * - `content` replaces the full content array + * - `details` replaces the full details payload + * - `isError` replaces the error flag + * - `terminate` replaces the early-termination hint + * + * Any omitted fields keep their original values. No deep merge is performed. + * The hook receives the agent abort signal and is responsible for honoring it. + */ + afterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise<AfterToolCallResult | undefined>; +} + +/** + * Thinking/reasoning level for models that support it. + * Note: "xhigh" is only supported by selected model families. Use model thinking-level metadata + * from @earendil-works/pi-ai to detect support for a concrete model. + */ +export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; + +/** + * Extensible interface for custom app messages. + * Apps can extend via declaration merging: + * + * @example + * ```typescript + * declare module "@mariozechner/agent" { + * interface CustomAgentMessages { + * artifact: ArtifactMessage; + * notification: NotificationMessage; + * } + * } + * ``` + */ +export interface CustomAgentMessages { + // Empty by default - apps extend via declaration merging +} + +/** + * AgentMessage: Union of LLM messages + custom messages. + * This abstraction allows apps to add custom message types while maintaining + * type safety and compatibility with the base LLM messages. + */ +export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages]; + +/** + * Public agent state. + * + * `tools` and `messages` use accessor properties so implementations can copy + * assigned arrays before storing them. + */ +export interface AgentState { + /** System prompt sent with each model request. */ + systemPrompt: string; + /** Active model used for future turns. */ + model: Model<any>; + /** Requested reasoning level for future turns. */ + thinkingLevel: ThinkingLevel; + /** Available tools. Assigning a new array copies the top-level array. */ + set tools(tools: AgentTool<any>[]); + get tools(): AgentTool<any>[]; + /** Conversation transcript. Assigning a new array copies the top-level array. */ + set messages(messages: AgentMessage[]); + get messages(): AgentMessage[]; + /** + * True while the agent is processing a prompt or continuation. + * + * This remains true until awaited `agent_end` listeners settle. + */ + readonly isStreaming: boolean; + /** Partial assistant message for the current streamed response, if any. */ + readonly streamingMessage?: AgentMessage; + /** Tool call ids currently executing. */ + readonly pendingToolCalls: ReadonlySet<string>; + /** Error message from the most recent failed or aborted assistant turn, if any. */ + readonly errorMessage?: string; +} + +/** Final or partial result produced by a tool. */ +export interface AgentToolResult<T> { + /** Text or image content returned to the model. */ + content: (TextContent | ImageContent)[]; + /** Arbitrary structured details for logs or UI rendering. */ + details: T; + /** + * Hint that the agent should stop after the current tool batch. + * Early termination only happens when every finalized tool result in the batch sets this to true. + */ + terminate?: boolean; +} + +/** Callback used by tools to stream partial execution updates. */ +export type AgentToolUpdateCallback<T = any> = (partialResult: AgentToolResult<T>) => void; + +/** Tool definition used by the agent runtime. */ +export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any> extends Tool<TParameters> { + /** Human-readable label for UI display. */ + label: string; + /** + * Optional compatibility shim for raw tool-call arguments before schema validation. + * Must return an object that matches `TParameters`. + */ + prepareArguments?: (args: unknown) => Static<TParameters>; + /** Execute the tool call. Throw on failure instead of encoding errors in `content`. */ + execute: ( + toolCallId: string, + params: Static<TParameters>, + signal?: AbortSignal, + onUpdate?: AgentToolUpdateCallback<TDetails>, + ) => Promise<AgentToolResult<TDetails>>; + /** + * Per-tool execution mode override. + * - "sequential": this tool must execute one at a time with other tool calls. + * - "parallel": this tool can execute concurrently with other tool calls. + * + * If omitted, the default execution mode applies. + */ + executionMode?: ToolExecutionMode; +} + +/** Context snapshot passed into the low-level agent loop. */ +export interface AgentContext { + /** System prompt included with the request. */ + systemPrompt: string; + /** Transcript visible to the model. */ + messages: AgentMessage[]; + /** Tools available for this run. */ + tools?: AgentTool<any>[]; +} + +/** + * Events emitted by the Agent for UI updates. + * + * `agent_end` is the last event emitted for a run, but awaited `Agent.subscribe()` + * listeners for that event are still part of run settlement. The agent becomes + * idle only after those listeners finish. + */ +export type AgentEvent = + // Agent lifecycle + | { type: "agent_start" } + | { type: "agent_end"; messages: AgentMessage[] } + // Turn lifecycle - a turn is one assistant response + any tool calls/results + | { type: "turn_start" } + | { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] } + // Message lifecycle - emitted for user, assistant, and toolResult messages + | { type: "message_start"; message: AgentMessage } + // Only emitted for assistant messages during streaming + | { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent } + | { type: "message_end"; message: AgentMessage } + // Tool execution lifecycle + | { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any } + | { type: "tool_execution_update"; toolCallId: string; toolName: string; args: any; partialResult: any } + | { type: "tool_execution_end"; toolCallId: string; toolName: string; result: any; isError: boolean }; diff --git a/emain/ai/api-registry.ts b/emain/ai/api-registry.ts new file mode 100644 index 000000000..1d5953c16 --- /dev/null +++ b/emain/ai/api-registry.ts @@ -0,0 +1,98 @@ +import type { + Api, + AssistantMessageEventStream, + Context, + Model, + SimpleStreamOptions, + StreamFunction, + StreamOptions, +} from "./types"; + +export type ApiStreamFunction = ( + model: Model<Api>, + context: Context, + options?: StreamOptions, +) => AssistantMessageEventStream; + +export type ApiStreamSimpleFunction = ( + model: Model<Api>, + context: Context, + options?: SimpleStreamOptions, +) => AssistantMessageEventStream; + +export interface ApiProvider<TApi extends Api = Api, TOptions extends StreamOptions = StreamOptions> { + api: TApi; + stream: StreamFunction<TApi, TOptions>; + streamSimple: StreamFunction<TApi, SimpleStreamOptions>; +} + +interface ApiProviderInternal { + api: Api; + stream: ApiStreamFunction; + streamSimple: ApiStreamSimpleFunction; +} + +type RegisteredApiProvider = { + provider: ApiProviderInternal; + sourceId?: string; +}; + +const apiProviderRegistry = new Map<string, RegisteredApiProvider>(); + +function wrapStream<TApi extends Api, TOptions extends StreamOptions>( + api: TApi, + stream: StreamFunction<TApi, TOptions>, +): ApiStreamFunction { + return (model, context, options) => { + if (model.api !== api) { + throw new Error(`Mismatched api: ${model.api} expected ${api}`); + } + return stream(model as Model<TApi>, context, options as TOptions); + }; +} + +function wrapStreamSimple<TApi extends Api>( + api: TApi, + streamSimple: StreamFunction<TApi, SimpleStreamOptions>, +): ApiStreamSimpleFunction { + return (model, context, options) => { + if (model.api !== api) { + throw new Error(`Mismatched api: ${model.api} expected ${api}`); + } + return streamSimple(model as Model<TApi>, context, options); + }; +} + +export function registerApiProvider<TApi extends Api, TOptions extends StreamOptions>( + provider: ApiProvider<TApi, TOptions>, + sourceId?: string, +): void { + apiProviderRegistry.set(provider.api, { + provider: { + api: provider.api, + stream: wrapStream(provider.api, provider.stream), + streamSimple: wrapStreamSimple(provider.api, provider.streamSimple), + }, + sourceId, + }); +} + +export function getApiProvider(api: Api): ApiProviderInternal | undefined { + return apiProviderRegistry.get(api)?.provider; +} + +export function getApiProviders(): ApiProviderInternal[] { + return Array.from(apiProviderRegistry.values(), (entry) => entry.provider); +} + +export function unregisterApiProviders(sourceId: string): void { + for (const [api, entry] of apiProviderRegistry.entries()) { + if (entry.sourceId === sourceId) { + apiProviderRegistry.delete(api); + } + } +} + +export function clearApiProviders(): void { + apiProviderRegistry.clear(); +} diff --git a/emain/ai/env-api-keys.ts b/emain/ai/env-api-keys.ts new file mode 100644 index 000000000..983f06a8a --- /dev/null +++ b/emain/ai/env-api-keys.ts @@ -0,0 +1,210 @@ +// NEVER convert to top-level imports - breaks browser/Vite builds +let _existsSync: typeof import("node:fs").existsSync | null = null; +let _homedir: typeof import("node:os").homedir | null = null; +let _join: typeof import("node:path").join | null = null; + +type DynamicImport = (specifier: string) => Promise<unknown>; + +const dynamicImport: DynamicImport = (specifier) => import(specifier); +const NODE_FS_SPECIFIER = "node:" + "fs"; +const NODE_OS_SPECIFIER = "node:" + "os"; +const NODE_PATH_SPECIFIER = "node:" + "path"; + +// Eagerly load in Node.js/Bun environment only +if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) { + dynamicImport(NODE_FS_SPECIFIER).then((m) => { + _existsSync = (m as typeof import("node:fs")).existsSync; + }); + dynamicImport(NODE_OS_SPECIFIER).then((m) => { + _homedir = (m as typeof import("node:os")).homedir; + }); + dynamicImport(NODE_PATH_SPECIFIER).then((m) => { + _join = (m as typeof import("node:path")).join; + }); +} + +import type { KnownProvider } from "./types"; + +let _procEnvCache: Map<string, string> | null = null; + +/** + * Fallback for https://github.com/oven-sh/bun/issues/27802 + * Bun compiled binaries have an empty `process.env` inside sandbox + * environments on Linux. We can recover the env from `/proc/self/environ`. + */ +function getProcEnv(key: string): string | undefined { + if (!process.versions?.bun) return undefined; + if (typeof process === "undefined") return undefined; + + // If process.env already has entries, the bug is not triggered. + if (Object.keys(process.env).length > 0) return undefined; + + if (_procEnvCache === null) { + _procEnvCache = new Map(); + try { + const { readFileSync } = require("node:fs") as typeof import("node:fs"); + const data = readFileSync("/proc/self/environ", "utf-8"); + for (const entry of data.split("\0")) { + const idx = entry.indexOf("="); + if (idx > 0) { + _procEnvCache.set(entry.slice(0, idx), entry.slice(idx + 1)); + } + } + } catch { + // /proc/self/environ may not be readable. + } + } + + return _procEnvCache.get(key); +} + +let cachedVertexAdcCredentialsExists: boolean | null = null; + +function hasVertexAdcCredentials(): boolean { + if (cachedVertexAdcCredentialsExists === null) { + // If node modules haven't loaded yet (async import race at startup), + // return false WITHOUT caching so the next call retries once they're ready. + // Only cache false permanently in a browser environment where fs is never available. + if (!_existsSync || !_homedir || !_join) { + const isNode = typeof process !== "undefined" && (process.versions?.node || process.versions?.bun); + if (!isNode) { + // Definitively in a browser — safe to cache false permanently + cachedVertexAdcCredentialsExists = false; + } + return false; + } + + // Check GOOGLE_APPLICATION_CREDENTIALS env var first (standard way) + const gacPath = process.env.GOOGLE_APPLICATION_CREDENTIALS || getProcEnv("GOOGLE_APPLICATION_CREDENTIALS"); + if (gacPath) { + cachedVertexAdcCredentialsExists = _existsSync(gacPath); + } else { + // Fall back to default ADC path (lazy evaluation) + cachedVertexAdcCredentialsExists = _existsSync( + _join(_homedir(), ".config", "gcloud", "application_default_credentials.json"), + ); + } + } + return cachedVertexAdcCredentialsExists; +} + +function getApiKeyEnvVars(provider: string): readonly string[] | undefined { + if (provider === "github-copilot") { + return ["COPILOT_GITHUB_TOKEN"]; + } + + // ANTHROPIC_OAUTH_TOKEN takes precedence over ANTHROPIC_API_KEY + if (provider === "anthropic") { + return ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]; + } + + const envMap: Record<string, string> = { + openai: "OPENAI_API_KEY", + "azure-openai-responses": "AZURE_OPENAI_API_KEY", + deepseek: "DEEPSEEK_API_KEY", + google: "GEMINI_API_KEY", + "google-vertex": "GOOGLE_CLOUD_API_KEY", + groq: "GROQ_API_KEY", + cerebras: "CEREBRAS_API_KEY", + xai: "XAI_API_KEY", + openrouter: "OPENROUTER_API_KEY", + "vercel-ai-gateway": "AI_GATEWAY_API_KEY", + zai: "ZAI_API_KEY", + mistral: "MISTRAL_API_KEY", + minimax: "MINIMAX_API_KEY", + "minimax-cn": "MINIMAX_CN_API_KEY", + moonshotai: "MOONSHOT_API_KEY", + "moonshotai-cn": "MOONSHOT_API_KEY", + huggingface: "HF_TOKEN", + fireworks: "FIREWORKS_API_KEY", + together: "TOGETHER_API_KEY", + opencode: "OPENCODE_API_KEY", + "opencode-go": "OPENCODE_API_KEY", + "kimi-coding": "KIMI_API_KEY", + "cloudflare-workers-ai": "CLOUDFLARE_API_KEY", + "cloudflare-ai-gateway": "CLOUDFLARE_API_KEY", + xiaomi: "XIAOMI_API_KEY", + "xiaomi-token-plan-cn": "XIAOMI_TOKEN_PLAN_CN_API_KEY", + "xiaomi-token-plan-ams": "XIAOMI_TOKEN_PLAN_AMS_API_KEY", + "xiaomi-token-plan-sgp": "XIAOMI_TOKEN_PLAN_SGP_API_KEY", + }; + + const envVar = envMap[provider]; + return envVar ? [envVar] : undefined; +} + +/** + * Find configured environment variables that can provide an API key for a provider. + * + * This only reports actual API key variables. It intentionally excludes ambient + * credential sources such as AWS profiles, AWS IAM credentials, and Google + * Application Default Credentials. + */ +export function findEnvKeys(provider: KnownProvider): string[] | undefined; +export function findEnvKeys(provider: string): string[] | undefined; +export function findEnvKeys(provider: string): string[] | undefined { + const envVars = getApiKeyEnvVars(provider); + if (!envVars) return undefined; + + const found = envVars.filter((envVar) => !!process.env[envVar] || !!getProcEnv(envVar)); + return found.length > 0 ? found : undefined; +} + +/** + * Get API key for provider from known environment variables, e.g. OPENAI_API_KEY. + * + * Will not return API keys for providers that require OAuth tokens. + */ +export function getEnvApiKey(provider: KnownProvider): string | undefined; +export function getEnvApiKey(provider: string): string | undefined; +export function getEnvApiKey(provider: string): string | undefined { + const envKeys = findEnvKeys(provider); + if (envKeys?.[0]) { + return process.env[envKeys[0]] || getProcEnv(envKeys[0]); + } + + // Vertex AI supports either an explicit API key or Application Default Credentials. + // Auth is configured via `gcloud auth application-default login`. + if (provider === "google-vertex") { + const hasCredentials = hasVertexAdcCredentials(); + const hasProject = !!( + process.env.GOOGLE_CLOUD_PROJECT || + process.env.GCLOUD_PROJECT || + getProcEnv("GOOGLE_CLOUD_PROJECT") || + getProcEnv("GCLOUD_PROJECT") + ); + const hasLocation = !!(process.env.GOOGLE_CLOUD_LOCATION || getProcEnv("GOOGLE_CLOUD_LOCATION")); + + if (hasCredentials && hasProject && hasLocation) { + return "<authenticated>"; + } + } + + if (provider === "amazon-bedrock") { + // Amazon Bedrock supports multiple credential sources: + // 1. AWS_PROFILE - named profile from ~/.aws/credentials + // 2. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY - standard IAM keys + // 3. AWS_BEARER_TOKEN_BEDROCK - Bedrock bearer token + // 4. AWS_CONTAINER_CREDENTIALS_RELATIVE_URI - ECS task roles + // 5. AWS_CONTAINER_CREDENTIALS_FULL_URI - ECS task roles (full URI) + // 6. AWS_WEB_IDENTITY_TOKEN_FILE - IRSA (IAM Roles for Service Accounts) + if ( + process.env.AWS_PROFILE || + (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) || + process.env.AWS_BEARER_TOKEN_BEDROCK || + process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || + process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI || + process.env.AWS_WEB_IDENTITY_TOKEN_FILE || + getProcEnv("AWS_PROFILE") || + (getProcEnv("AWS_ACCESS_KEY_ID") && getProcEnv("AWS_SECRET_ACCESS_KEY")) || + getProcEnv("AWS_BEARER_TOKEN_BEDROCK") || + getProcEnv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") || + getProcEnv("AWS_CONTAINER_CREDENTIALS_FULL_URI") || + getProcEnv("AWS_WEB_IDENTITY_TOKEN_FILE") + ) { + return "<authenticated>"; + } + } + + return undefined; +} diff --git a/emain/ai/index.ts b/emain/ai/index.ts new file mode 100644 index 000000000..9c2f4995e --- /dev/null +++ b/emain/ai/index.ts @@ -0,0 +1,42 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +// +// Public surface of crest's integrated AI client (started from pi-ai +// v0.75.5, now owned in-tree). Image generation, Bedrock, Vertex, +// Azure, Codex, Mistral, Cloudflare, Faux are stripped — re-add by +// copying back from upstream and re-exporting here. + +export type { Static, TSchema } from "typebox"; +export { Type } from "typebox"; + +export * from "./api-registry"; +export * from "./env-api-keys"; +export * from "./models"; +export type { AnthropicEffort, AnthropicOptions, AnthropicThinkingDisplay } from "./providers/anthropic"; +export type { GoogleOptions } from "./providers/google"; +export type { GoogleThinkingLevel } from "./providers/google-shared"; +export type { OpenAICompletionsOptions } from "./providers/openai-completions"; +export type { OpenAIResponsesOptions } from "./providers/openai-responses"; +export * from "./providers/register-builtins"; +export * from "./session-resources"; +export * from "./stream"; +export * from "./types"; +export * from "./utils/diagnostics"; +export * from "./utils/event-stream"; +export * from "./utils/json-parse"; +export type { + OAuthAuthInfo, + OAuthCredentials, + OAuthDeviceCodeInfo, + OAuthLoginCallbacks, + OAuthPrompt, + OAuthProvider, + OAuthProviderId, + OAuthProviderInfo, + OAuthProviderInterface, + OAuthSelectOption, + OAuthSelectPrompt, +} from "./utils/oauth/types"; +export * from "./utils/overflow"; +export * from "./utils/typebox-helpers"; +export * from "./utils/validation"; diff --git a/emain/ai/models.generated.ts b/emain/ai/models.generated.ts new file mode 100644 index 000000000..860026988 --- /dev/null +++ b/emain/ai/models.generated.ts @@ -0,0 +1,16115 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "./types"; + +export const MODELS = { + "amazon-bedrock": { + "amazon.nova-2-lite-v1:0": { + id: "amazon.nova-2-lite-v1:0", + name: "Nova 2 Lite", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.33, + output: 2.75, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"bedrock-converse-stream">, + "amazon.nova-lite-v1:0": { + id: "amazon.nova-lite-v1:0", + name: "Nova Lite", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.06, + output: 0.24, + cacheRead: 0.015, + cacheWrite: 0, + }, + contextWindow: 300000, + maxTokens: 8192, + } satisfies Model<"bedrock-converse-stream">, + "amazon.nova-micro-v1:0": { + id: "amazon.nova-micro-v1:0", + name: "Nova Micro", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text"], + cost: { + input: 0.035, + output: 0.14, + cacheRead: 0.00875, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 8192, + } satisfies Model<"bedrock-converse-stream">, + "amazon.nova-pro-v1:0": { + id: "amazon.nova-pro-v1:0", + name: "Nova Pro", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.8, + output: 3.2, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 300000, + maxTokens: 8192, + } satisfies Model<"bedrock-converse-stream">, + "anthropic.claude-haiku-4-5-20251001-v1:0": { + id: "anthropic.claude-haiku-4-5-20251001-v1:0", + name: "Claude Haiku 4.5", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 5, + cacheRead: 0.1, + cacheWrite: 1.25, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"bedrock-converse-stream">, + "anthropic.claude-opus-4-1-20250805-v1:0": { + id: "anthropic.claude-opus-4-1-20250805-v1:0", + name: "Claude Opus 4.1", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + contextWindow: 200000, + maxTokens: 32000, + } satisfies Model<"bedrock-converse-stream">, + "anthropic.claude-opus-4-5-20251101-v1:0": { + id: "anthropic.claude-opus-4-5-20251101-v1:0", + name: "Claude Opus 4.5", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"bedrock-converse-stream">, + "anthropic.claude-opus-4-6-v1": { + id: "anthropic.claude-opus-4-6-v1", + name: "Claude Opus 4.6", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + thinkingLevelMap: {"xhigh":"max"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, + "anthropic.claude-opus-4-7": { + id: "anthropic.claude-opus-4-7", + name: "Claude Opus 4.7", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, + "anthropic.claude-sonnet-4-5-20250929-v1:0": { + id: "anthropic.claude-sonnet-4-5-20250929-v1:0", + name: "Claude Sonnet 4.5", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"bedrock-converse-stream">, + "anthropic.claude-sonnet-4-6": { + id: "anthropic.claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"bedrock-converse-stream">, + "au.anthropic.claude-haiku-4-5-20251001-v1:0": { + id: "au.anthropic.claude-haiku-4-5-20251001-v1:0", + name: "Claude Haiku 4.5 (AU)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 5, + cacheRead: 0.1, + cacheWrite: 1.25, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"bedrock-converse-stream">, + "au.anthropic.claude-opus-4-6-v1": { + id: "au.anthropic.claude-opus-4-6-v1", + name: "AU Anthropic Claude Opus 4.6", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + thinkingLevelMap: {"xhigh":"max"}, + input: ["text", "image"], + cost: { + input: 16.5, + output: 82.5, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, + "au.anthropic.claude-sonnet-4-5-20250929-v1:0": { + id: "au.anthropic.claude-sonnet-4-5-20250929-v1:0", + name: "Claude Sonnet 4.5 (AU)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"bedrock-converse-stream">, + "au.anthropic.claude-sonnet-4-6": { + id: "au.anthropic.claude-sonnet-4-6", + name: "AU Anthropic Claude Sonnet 4.6", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3.3, + output: 16.5, + cacheRead: 0.33, + cacheWrite: 4.125, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, + "deepseek.r1-v1:0": { + id: "deepseek.r1-v1:0", + name: "DeepSeek-R1", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text"], + cost: { + input: 1.35, + output: 5.4, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 32768, + } satisfies Model<"bedrock-converse-stream">, + "deepseek.v3-v1:0": { + id: "deepseek.v3-v1:0", + name: "DeepSeek-V3.1", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text"], + cost: { + input: 0.58, + output: 1.68, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 81920, + } satisfies Model<"bedrock-converse-stream">, + "deepseek.v3.2": { + id: "deepseek.v3.2", + name: "DeepSeek-V3.2", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text"], + cost: { + input: 0.62, + output: 1.85, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 81920, + } satisfies Model<"bedrock-converse-stream">, + "eu.anthropic.claude-haiku-4-5-20251001-v1:0": { + id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", + name: "Claude Haiku 4.5 (EU)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.eu-central-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 5, + cacheRead: 0.1, + cacheWrite: 1.25, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"bedrock-converse-stream">, + "eu.anthropic.claude-opus-4-5-20251101-v1:0": { + id: "eu.anthropic.claude-opus-4-5-20251101-v1:0", + name: "Claude Opus 4.5 (EU)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.eu-central-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"bedrock-converse-stream">, + "eu.anthropic.claude-opus-4-6-v1": { + id: "eu.anthropic.claude-opus-4-6-v1", + name: "Claude Opus 4.6 (EU)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.eu-central-1.amazonaws.com", + reasoning: true, + thinkingLevelMap: {"xhigh":"max"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, + "eu.anthropic.claude-opus-4-7": { + id: "eu.anthropic.claude-opus-4-7", + name: "Claude Opus 4.7 (EU)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.eu-central-1.amazonaws.com", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, + "eu.anthropic.claude-sonnet-4-5-20250929-v1:0": { + id: "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", + name: "Claude Sonnet 4.5 (EU)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.eu-central-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"bedrock-converse-stream">, + "eu.anthropic.claude-sonnet-4-6": { + id: "eu.anthropic.claude-sonnet-4-6", + name: "Claude Sonnet 4.6 (EU)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.eu-central-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"bedrock-converse-stream">, + "global.anthropic.claude-haiku-4-5-20251001-v1:0": { + id: "global.anthropic.claude-haiku-4-5-20251001-v1:0", + name: "Claude Haiku 4.5 (Global)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 5, + cacheRead: 0.1, + cacheWrite: 1.25, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"bedrock-converse-stream">, + "global.anthropic.claude-opus-4-5-20251101-v1:0": { + id: "global.anthropic.claude-opus-4-5-20251101-v1:0", + name: "Claude Opus 4.5 (Global)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"bedrock-converse-stream">, + "global.anthropic.claude-opus-4-6-v1": { + id: "global.anthropic.claude-opus-4-6-v1", + name: "Claude Opus 4.6 (Global)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + thinkingLevelMap: {"xhigh":"max"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, + "global.anthropic.claude-opus-4-7": { + id: "global.anthropic.claude-opus-4-7", + name: "Claude Opus 4.7 (Global)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, + "global.anthropic.claude-sonnet-4-5-20250929-v1:0": { + id: "global.anthropic.claude-sonnet-4-5-20250929-v1:0", + name: "Claude Sonnet 4.5 (Global)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"bedrock-converse-stream">, + "global.anthropic.claude-sonnet-4-6": { + id: "global.anthropic.claude-sonnet-4-6", + name: "Claude Sonnet 4.6 (Global)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"bedrock-converse-stream">, + "google.gemma-3-27b-it": { + id: "google.gemma-3-27b-it", + name: "Google Gemma 3 27B Instruct", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.12, + output: 0.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 202752, + maxTokens: 8192, + } satisfies Model<"bedrock-converse-stream">, + "google.gemma-3-4b-it": { + id: "google.gemma-3-4b-it", + name: "Gemma 3 4B IT", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.04, + output: 0.08, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"bedrock-converse-stream">, + "jp.anthropic.claude-opus-4-7": { + id: "jp.anthropic.claude-opus-4-7", + name: "Claude Opus 4.7 (JP)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, + "jp.anthropic.claude-sonnet-4-5-20250929-v1:0": { + id: "jp.anthropic.claude-sonnet-4-5-20250929-v1:0", + name: "Claude Sonnet 4.5 (JP)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"bedrock-converse-stream">, + "jp.anthropic.claude-sonnet-4-6": { + id: "jp.anthropic.claude-sonnet-4-6", + name: "Claude Sonnet 4.6 (JP)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"bedrock-converse-stream">, + "meta.llama3-1-70b-instruct-v1:0": { + id: "meta.llama3-1-70b-instruct-v1:0", + name: "Llama 3.1 70B Instruct", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text"], + cost: { + input: 0.72, + output: 0.72, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"bedrock-converse-stream">, + "meta.llama3-1-8b-instruct-v1:0": { + id: "meta.llama3-1-8b-instruct-v1:0", + name: "Llama 3.1 8B Instruct", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text"], + cost: { + input: 0.22, + output: 0.22, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"bedrock-converse-stream">, + "meta.llama3-3-70b-instruct-v1:0": { + id: "meta.llama3-3-70b-instruct-v1:0", + name: "Llama 3.3 70B Instruct", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text"], + cost: { + input: 0.72, + output: 0.72, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"bedrock-converse-stream">, + "meta.llama4-maverick-17b-instruct-v1:0": { + id: "meta.llama4-maverick-17b-instruct-v1:0", + name: "Llama 4 Maverick 17B Instruct", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.24, + output: 0.97, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 16384, + } satisfies Model<"bedrock-converse-stream">, + "meta.llama4-scout-17b-instruct-v1:0": { + id: "meta.llama4-scout-17b-instruct-v1:0", + name: "Llama 4 Scout 17B Instruct", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.17, + output: 0.66, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 3500000, + maxTokens: 16384, + } satisfies Model<"bedrock-converse-stream">, + "minimax.minimax-m2": { + id: "minimax.minimax-m2", + name: "MiniMax M2", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 204608, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, + "minimax.minimax-m2.1": { + id: "minimax.minimax-m2.1", + name: "MiniMax M2.1", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"bedrock-converse-stream">, + "minimax.minimax-m2.5": { + id: "minimax.minimax-m2.5", + name: "MiniMax M2.5", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 196608, + maxTokens: 98304, + } satisfies Model<"bedrock-converse-stream">, + "mistral.devstral-2-123b": { + id: "mistral.devstral-2-123b", + name: "Devstral 2 123B", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 8192, + } satisfies Model<"bedrock-converse-stream">, + "mistral.magistral-small-2509": { + id: "mistral.magistral-small-2509", + name: "Magistral Small 1.2", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.5, + output: 1.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 40000, + } satisfies Model<"bedrock-converse-stream">, + "mistral.ministral-3-14b-instruct": { + id: "mistral.ministral-3-14b-instruct", + name: "Ministral 14B 3.0", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text"], + cost: { + input: 0.2, + output: 0.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"bedrock-converse-stream">, + "mistral.ministral-3-3b-instruct": { + id: "mistral.ministral-3-3b-instruct", + name: "Ministral 3 3B", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.1, + output: 0.1, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 8192, + } satisfies Model<"bedrock-converse-stream">, + "mistral.ministral-3-8b-instruct": { + id: "mistral.ministral-3-8b-instruct", + name: "Ministral 3 8B", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text"], + cost: { + input: 0.15, + output: 0.15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"bedrock-converse-stream">, + "mistral.mistral-large-3-675b-instruct": { + id: "mistral.mistral-large-3-675b-instruct", + name: "Mistral Large 3", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.5, + output: 1.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 8192, + } satisfies Model<"bedrock-converse-stream">, + "mistral.pixtral-large-2502-v1:0": { + id: "mistral.pixtral-large-2502-v1:0", + name: "Pixtral Large (25.02)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2, + output: 6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 8192, + } satisfies Model<"bedrock-converse-stream">, + "mistral.voxtral-mini-3b-2507": { + id: "mistral.voxtral-mini-3b-2507", + name: "Voxtral Mini 3B 2507", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text"], + cost: { + input: 0.04, + output: 0.04, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"bedrock-converse-stream">, + "mistral.voxtral-small-24b-2507": { + id: "mistral.voxtral-small-24b-2507", + name: "Voxtral Small 24B 2507", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text"], + cost: { + input: 0.15, + output: 0.35, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32000, + maxTokens: 8192, + } satisfies Model<"bedrock-converse-stream">, + "moonshot.kimi-k2-thinking": { + id: "moonshot.kimi-k2-thinking", + name: "Kimi K2 Thinking", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text"], + cost: { + input: 0.6, + output: 2.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262143, + maxTokens: 16000, + } satisfies Model<"bedrock-converse-stream">, + "moonshotai.kimi-k2.5": { + id: "moonshotai.kimi-k2.5", + name: "Kimi K2.5", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.6, + output: 3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262143, + maxTokens: 16000, + } satisfies Model<"bedrock-converse-stream">, + "nvidia.nemotron-nano-12b-v2": { + id: "nvidia.nemotron-nano-12b-v2", + name: "NVIDIA Nemotron Nano 12B v2 VL BF16", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.2, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"bedrock-converse-stream">, + "nvidia.nemotron-nano-3-30b": { + id: "nvidia.nemotron-nano-3-30b", + name: "NVIDIA Nemotron Nano 3 30B", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text"], + cost: { + input: 0.06, + output: 0.24, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"bedrock-converse-stream">, + "nvidia.nemotron-nano-9b-v2": { + id: "nvidia.nemotron-nano-9b-v2", + name: "NVIDIA Nemotron Nano 9B v2", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text"], + cost: { + input: 0.06, + output: 0.23, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"bedrock-converse-stream">, + "nvidia.nemotron-super-3-120b": { + id: "nvidia.nemotron-super-3-120b", + name: "NVIDIA Nemotron 3 Super 120B A12B", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text"], + cost: { + input: 0.15, + output: 0.65, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 131072, + } satisfies Model<"bedrock-converse-stream">, + "openai.gpt-oss-120b-1:0": { + id: "openai.gpt-oss-120b-1:0", + name: "gpt-oss-120b", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"bedrock-converse-stream">, + "openai.gpt-oss-20b-1:0": { + id: "openai.gpt-oss-20b-1:0", + name: "gpt-oss-20b", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text"], + cost: { + input: 0.07, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"bedrock-converse-stream">, + "openai.gpt-oss-safeguard-120b": { + id: "openai.gpt-oss-safeguard-120b", + name: "GPT OSS Safeguard 120B", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"bedrock-converse-stream">, + "openai.gpt-oss-safeguard-20b": { + id: "openai.gpt-oss-safeguard-20b", + name: "GPT OSS Safeguard 20B", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text"], + cost: { + input: 0.07, + output: 0.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"bedrock-converse-stream">, + "qwen.qwen3-235b-a22b-2507-v1:0": { + id: "qwen.qwen3-235b-a22b-2507-v1:0", + name: "Qwen3 235B A22B 2507", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text"], + cost: { + input: 0.22, + output: 0.88, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 131072, + } satisfies Model<"bedrock-converse-stream">, + "qwen.qwen3-32b-v1:0": { + id: "qwen.qwen3-32b-v1:0", + name: "Qwen3 32B (dense)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 16384, + maxTokens: 16384, + } satisfies Model<"bedrock-converse-stream">, + "qwen.qwen3-coder-30b-a3b-v1:0": { + id: "qwen.qwen3-coder-30b-a3b-v1:0", + name: "Qwen3 Coder 30B A3B Instruct", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 131072, + } satisfies Model<"bedrock-converse-stream">, + "qwen.qwen3-coder-480b-a35b-v1:0": { + id: "qwen.qwen3-coder-480b-a35b-v1:0", + name: "Qwen3 Coder 480B A35B Instruct", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text"], + cost: { + input: 0.22, + output: 1.8, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 65536, + } satisfies Model<"bedrock-converse-stream">, + "qwen.qwen3-coder-next": { + id: "qwen.qwen3-coder-next", + name: "Qwen3 Coder Next", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text"], + cost: { + input: 0.22, + output: 1.8, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 65536, + } satisfies Model<"bedrock-converse-stream">, + "qwen.qwen3-next-80b-a3b": { + id: "qwen.qwen3-next-80b-a3b", + name: "Qwen/Qwen3-Next-80B-A3B-Instruct", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text"], + cost: { + input: 0.14, + output: 1.4, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262000, + maxTokens: 262000, + } satisfies Model<"bedrock-converse-stream">, + "qwen.qwen3-vl-235b-a22b": { + id: "qwen.qwen3-vl-235b-a22b", + name: "Qwen/Qwen3-VL-235B-A22B-Instruct", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.3, + output: 1.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262000, + maxTokens: 262000, + } satisfies Model<"bedrock-converse-stream">, + "us.anthropic.claude-haiku-4-5-20251001-v1:0": { + id: "us.anthropic.claude-haiku-4-5-20251001-v1:0", + name: "Claude Haiku 4.5 (US)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 5, + cacheRead: 0.1, + cacheWrite: 1.25, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"bedrock-converse-stream">, + "us.anthropic.claude-opus-4-1-20250805-v1:0": { + id: "us.anthropic.claude-opus-4-1-20250805-v1:0", + name: "Claude Opus 4.1 (US)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + contextWindow: 200000, + maxTokens: 32000, + } satisfies Model<"bedrock-converse-stream">, + "us.anthropic.claude-opus-4-5-20251101-v1:0": { + id: "us.anthropic.claude-opus-4-5-20251101-v1:0", + name: "Claude Opus 4.5 (US)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"bedrock-converse-stream">, + "us.anthropic.claude-opus-4-6-v1": { + id: "us.anthropic.claude-opus-4-6-v1", + name: "Claude Opus 4.6 (US)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + thinkingLevelMap: {"xhigh":"max"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, + "us.anthropic.claude-opus-4-7": { + id: "us.anthropic.claude-opus-4-7", + name: "Claude Opus 4.7 (US)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, + "us.anthropic.claude-sonnet-4-5-20250929-v1:0": { + id: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", + name: "Claude Sonnet 4.5 (US)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"bedrock-converse-stream">, + "us.anthropic.claude-sonnet-4-6": { + id: "us.anthropic.claude-sonnet-4-6", + name: "Claude Sonnet 4.6 (US)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"bedrock-converse-stream">, + "us.deepseek.r1-v1:0": { + id: "us.deepseek.r1-v1:0", + name: "DeepSeek-R1 (US)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text"], + cost: { + input: 1.35, + output: 5.4, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 32768, + } satisfies Model<"bedrock-converse-stream">, + "us.meta.llama4-maverick-17b-instruct-v1:0": { + id: "us.meta.llama4-maverick-17b-instruct-v1:0", + name: "Llama 4 Maverick 17B Instruct (US)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.24, + output: 0.97, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 16384, + } satisfies Model<"bedrock-converse-stream">, + "us.meta.llama4-scout-17b-instruct-v1:0": { + id: "us.meta.llama4-scout-17b-instruct-v1:0", + name: "Llama 4 Scout 17B Instruct (US)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.17, + output: 0.66, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 3500000, + maxTokens: 16384, + } satisfies Model<"bedrock-converse-stream">, + "writer.palmyra-x4-v1:0": { + id: "writer.palmyra-x4-v1:0", + name: "Palmyra X4", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text"], + cost: { + input: 2.5, + output: 10, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 122880, + maxTokens: 8192, + } satisfies Model<"bedrock-converse-stream">, + "writer.palmyra-x5-v1:0": { + id: "writer.palmyra-x5-v1:0", + name: "Palmyra X5", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text"], + cost: { + input: 0.6, + output: 6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1040000, + maxTokens: 8192, + } satisfies Model<"bedrock-converse-stream">, + "zai.glm-4.7": { + id: "zai.glm-4.7", + name: "GLM-4.7", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text"], + cost: { + input: 0.6, + output: 2.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"bedrock-converse-stream">, + "zai.glm-4.7-flash": { + id: "zai.glm-4.7-flash", + name: "GLM-4.7-Flash", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text"], + cost: { + input: 0.07, + output: 0.4, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 131072, + } satisfies Model<"bedrock-converse-stream">, + "zai.glm-5": { + id: "zai.glm-5", + name: "GLM-5", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text"], + cost: { + input: 1, + output: 3.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 202752, + maxTokens: 101376, + } satisfies Model<"bedrock-converse-stream">, + }, + "anthropic": { + "claude-3-5-haiku-20241022": { + id: "claude-3-5-haiku-20241022", + name: "Claude Haiku 3.5", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.8, + output: 4, + cacheRead: 0.08, + cacheWrite: 1, + }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"anthropic-messages">, + "claude-3-5-haiku-latest": { + id: "claude-3-5-haiku-latest", + name: "Claude Haiku 3.5 (latest)", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.8, + output: 4, + cacheRead: 0.08, + cacheWrite: 1, + }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"anthropic-messages">, + "claude-3-5-sonnet-20240620": { + id: "claude-3-5-sonnet-20240620", + name: "Claude Sonnet 3.5", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"anthropic-messages">, + "claude-3-5-sonnet-20241022": { + id: "claude-3-5-sonnet-20241022", + name: "Claude Sonnet 3.5 v2", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"anthropic-messages">, + "claude-3-7-sonnet-20250219": { + id: "claude-3-7-sonnet-20250219", + name: "Claude Sonnet 3.7", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "claude-3-haiku-20240307": { + id: "claude-3-haiku-20240307", + name: "Claude Haiku 3", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.25, + output: 1.25, + cacheRead: 0.03, + cacheWrite: 0.3, + }, + contextWindow: 200000, + maxTokens: 4096, + } satisfies Model<"anthropic-messages">, + "claude-3-opus-20240229": { + id: "claude-3-opus-20240229", + name: "Claude Opus 3", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + contextWindow: 200000, + maxTokens: 4096, + } satisfies Model<"anthropic-messages">, + "claude-3-sonnet-20240229": { + id: "claude-3-sonnet-20240229", + name: "Claude Sonnet 3", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 0.3, + }, + contextWindow: 200000, + maxTokens: 4096, + } satisfies Model<"anthropic-messages">, + "claude-haiku-4-5": { + id: "claude-haiku-4-5", + name: "Claude Haiku 4.5 (latest)", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 5, + cacheRead: 0.1, + cacheWrite: 1.25, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "claude-haiku-4-5-20251001": { + id: "claude-haiku-4-5-20251001", + name: "Claude Haiku 4.5", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 5, + cacheRead: 0.1, + cacheWrite: 1.25, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "claude-opus-4-0": { + id: "claude-opus-4-0", + name: "Claude Opus 4 (latest)", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + contextWindow: 200000, + maxTokens: 32000, + } satisfies Model<"anthropic-messages">, + "claude-opus-4-1": { + id: "claude-opus-4-1", + name: "Claude Opus 4.1 (latest)", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + contextWindow: 200000, + maxTokens: 32000, + } satisfies Model<"anthropic-messages">, + "claude-opus-4-1-20250805": { + id: "claude-opus-4-1-20250805", + name: "Claude Opus 4.1", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + contextWindow: 200000, + maxTokens: 32000, + } satisfies Model<"anthropic-messages">, + "claude-opus-4-20250514": { + id: "claude-opus-4-20250514", + name: "Claude Opus 4", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + contextWindow: 200000, + maxTokens: 32000, + } satisfies Model<"anthropic-messages">, + "claude-opus-4-5": { + id: "claude-opus-4-5", + name: "Claude Opus 4.5 (latest)", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "claude-opus-4-5-20251101": { + id: "claude-opus-4-5-20251101", + name: "Claude Opus 4.5", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "claude-opus-4-6": { + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"max"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "claude-opus-4-7": { + id: "claude-opus-4-7", + name: "Claude Opus 4.7", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "claude-sonnet-4-0": { + id: "claude-sonnet-4-0", + name: "Claude Sonnet 4 (latest)", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "claude-sonnet-4-20250514": { + id: "claude-sonnet-4-20250514", + name: "Claude Sonnet 4", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "claude-sonnet-4-5": { + id: "claude-sonnet-4-5", + name: "Claude Sonnet 4.5 (latest)", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "claude-sonnet-4-5-20250929": { + id: "claude-sonnet-4-5-20250929", + name: "Claude Sonnet 4.5", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "claude-sonnet-4-6": { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + }, + "azure-openai-responses": { + "gpt-4": { + id: "gpt-4", + name: "GPT-4", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: false, + input: ["text"], + cost: { + input: 30, + output: 60, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 8192, + } satisfies Model<"azure-openai-responses">, + "gpt-4-turbo": { + id: "gpt-4-turbo", + name: "GPT-4 Turbo", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: false, + input: ["text", "image"], + cost: { + input: 10, + output: 30, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"azure-openai-responses">, + "gpt-4.1": { + id: "gpt-4.1", + name: "GPT-4.1", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2, + output: 8, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 1047576, + maxTokens: 32768, + } satisfies Model<"azure-openai-responses">, + "gpt-4.1-mini": { + id: "gpt-4.1-mini", + name: "GPT-4.1 mini", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.4, + output: 1.6, + cacheRead: 0.1, + cacheWrite: 0, + }, + contextWindow: 1047576, + maxTokens: 32768, + } satisfies Model<"azure-openai-responses">, + "gpt-4.1-nano": { + id: "gpt-4.1-nano", + name: "GPT-4.1 nano", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.1, + output: 0.4, + cacheRead: 0.03, + cacheWrite: 0, + }, + contextWindow: 1047576, + maxTokens: 32768, + } satisfies Model<"azure-openai-responses">, + "gpt-4o": { + id: "gpt-4o", + name: "GPT-4o", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2.5, + output: 10, + cacheRead: 1.25, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"azure-openai-responses">, + "gpt-4o-2024-05-13": { + id: "gpt-4o-2024-05-13", + name: "GPT-4o (2024-05-13)", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: false, + input: ["text", "image"], + cost: { + input: 5, + output: 15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"azure-openai-responses">, + "gpt-4o-2024-08-06": { + id: "gpt-4o-2024-08-06", + name: "GPT-4o (2024-08-06)", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2.5, + output: 10, + cacheRead: 1.25, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"azure-openai-responses">, + "gpt-4o-2024-11-20": { + id: "gpt-4o-2024-11-20", + name: "GPT-4o (2024-11-20)", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2.5, + output: 10, + cacheRead: 1.25, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"azure-openai-responses">, + "gpt-4o-mini": { + id: "gpt-4o-mini", + name: "GPT-4o mini", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"azure-openai-responses">, + "gpt-5": { + id: "gpt-5", + name: "GPT-5", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"azure-openai-responses">, + "gpt-5-chat-latest": { + id: "gpt-5-chat-latest", + name: "GPT-5 Chat Latest", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: false, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"azure-openai-responses">, + "gpt-5-codex": { + id: "gpt-5-codex", + name: "GPT-5-Codex", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"azure-openai-responses">, + "gpt-5-mini": { + id: "gpt-5-mini", + name: "GPT-5 Mini", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 0.25, + output: 2, + cacheRead: 0.025, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"azure-openai-responses">, + "gpt-5-nano": { + id: "gpt-5-nano", + name: "GPT-5 Nano", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 0.05, + output: 0.4, + cacheRead: 0.005, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"azure-openai-responses">, + "gpt-5-pro": { + id: "gpt-5-pro", + name: "GPT-5 Pro", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 15, + output: 120, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 272000, + } satisfies Model<"azure-openai-responses">, + "gpt-5.1": { + id: "gpt-5.1", + name: "GPT-5.1", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.13, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"azure-openai-responses">, + "gpt-5.1-chat-latest": { + id: "gpt-5.1-chat-latest", + name: "GPT-5.1 Chat", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"azure-openai-responses">, + "gpt-5.1-codex": { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"azure-openai-responses">, + "gpt-5.1-codex-max": { + id: "gpt-5.1-codex-max", + name: "GPT-5.1 Codex Max", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"azure-openai-responses">, + "gpt-5.1-codex-mini": { + id: "gpt-5.1-codex-mini", + name: "GPT-5.1 Codex mini", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 0.25, + output: 2, + cacheRead: 0.025, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"azure-openai-responses">, + "gpt-5.2": { + id: "gpt-5.2", + name: "GPT-5.2", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"azure-openai-responses">, + "gpt-5.2-chat-latest": { + id: "gpt-5.2-chat-latest", + name: "GPT-5.2 Chat", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"azure-openai-responses">, + "gpt-5.2-codex": { + id: "gpt-5.2-codex", + name: "GPT-5.2 Codex", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"azure-openai-responses">, + "gpt-5.2-pro": { + id: "gpt-5.2-pro", + name: "GPT-5.2 Pro", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 21, + output: 168, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"azure-openai-responses">, + "gpt-5.3-chat-latest": { + id: "gpt-5.3-chat-latest", + name: "GPT-5.3 Chat (latest)", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: false, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"azure-openai-responses">, + "gpt-5.3-codex": { + id: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"azure-openai-responses">, + "gpt-5.3-codex-spark": { + id: "gpt-5.3-codex-spark", + name: "GPT-5.3 Codex Spark", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 32000, + } satisfies Model<"azure-openai-responses">, + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 2.5, + output: 15, + cacheRead: 0.25, + cacheWrite: 0, + }, + contextWindow: 272000, + maxTokens: 128000, + } satisfies Model<"azure-openai-responses">, + "gpt-5.4-mini": { + id: "gpt-5.4-mini", + name: "GPT-5.4 mini", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 0.75, + output: 4.5, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"azure-openai-responses">, + "gpt-5.4-nano": { + id: "gpt-5.4-nano", + name: "GPT-5.4 nano", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 0.2, + output: 1.25, + cacheRead: 0.02, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"azure-openai-responses">, + "gpt-5.4-pro": { + id: "gpt-5.4-pro", + name: "GPT-5.4 Pro", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 30, + output: 180, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"azure-openai-responses">, + "gpt-5.5": { + id: "gpt-5.5", + name: "GPT-5.5", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 30, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 272000, + maxTokens: 128000, + } satisfies Model<"azure-openai-responses">, + "gpt-5.5-pro": { + id: "gpt-5.5-pro", + name: "GPT-5.5 Pro", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 30, + output: 180, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"azure-openai-responses">, + "o1": { + id: "o1", + name: "o1", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 60, + cacheRead: 7.5, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"azure-openai-responses">, + "o1-pro": { + id: "o1-pro", + name: "o1-pro", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + input: ["text", "image"], + cost: { + input: 150, + output: 600, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"azure-openai-responses">, + "o3": { + id: "o3", + name: "o3", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 8, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"azure-openai-responses">, + "o3-deep-research": { + id: "o3-deep-research", + name: "o3-deep-research", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + input: ["text", "image"], + cost: { + input: 10, + output: 40, + cacheRead: 2.5, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"azure-openai-responses">, + "o3-mini": { + id: "o3-mini", + name: "o3-mini", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + input: ["text"], + cost: { + input: 1.1, + output: 4.4, + cacheRead: 0.55, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"azure-openai-responses">, + "o3-pro": { + id: "o3-pro", + name: "o3-pro", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + input: ["text", "image"], + cost: { + input: 20, + output: 80, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"azure-openai-responses">, + "o4-mini": { + id: "o4-mini", + name: "o4-mini", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.1, + output: 4.4, + cacheRead: 0.28, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"azure-openai-responses">, + "o4-mini-deep-research": { + id: "o4-mini-deep-research", + name: "o4-mini-deep-research", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 8, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"azure-openai-responses">, + }, + "cerebras": { + "gpt-oss-120b": { + id: "gpt-oss-120b", + name: "GPT OSS 120B", + api: "openai-completions", + provider: "cerebras", + baseUrl: "https://api.cerebras.ai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.25, + output: 0.69, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "llama3.1-8b": { + id: "llama3.1-8b", + name: "Llama 3.1 8B", + api: "openai-completions", + provider: "cerebras", + baseUrl: "https://api.cerebras.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.1, + output: 0.1, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32000, + maxTokens: 8000, + } satisfies Model<"openai-completions">, + "qwen-3-235b-a22b-instruct-2507": { + id: "qwen-3-235b-a22b-instruct-2507", + name: "Qwen 3 235B Instruct", + api: "openai-completions", + provider: "cerebras", + baseUrl: "https://api.cerebras.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.6, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131000, + maxTokens: 32000, + } satisfies Model<"openai-completions">, + "zai-glm-4.7": { + id: "zai-glm-4.7", + name: "Z.AI GLM-4.7", + api: "openai-completions", + provider: "cerebras", + baseUrl: "https://api.cerebras.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2.25, + output: 2.75, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 40000, + } satisfies Model<"openai-completions">, + }, + "cloudflare-ai-gateway": { + "claude-3-5-haiku": { + id: "claude-3-5-haiku", + name: "Claude Haiku 3.5 (latest)", + api: "anthropic-messages", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.8, + output: 4, + cacheRead: 0.08, + cacheWrite: 1, + }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"anthropic-messages">, + "claude-3-haiku": { + id: "claude-3-haiku", + name: "Claude Haiku 3", + api: "anthropic-messages", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.25, + output: 1.25, + cacheRead: 0.03, + cacheWrite: 0.3, + }, + contextWindow: 200000, + maxTokens: 4096, + } satisfies Model<"anthropic-messages">, + "claude-3-opus": { + id: "claude-3-opus", + name: "Claude Opus 3", + api: "anthropic-messages", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + reasoning: false, + input: ["text", "image"], + cost: { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + contextWindow: 200000, + maxTokens: 4096, + } satisfies Model<"anthropic-messages">, + "claude-3-sonnet": { + id: "claude-3-sonnet", + name: "Claude Sonnet 3", + api: "anthropic-messages", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + reasoning: false, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 0.3, + }, + contextWindow: 200000, + maxTokens: 4096, + } satisfies Model<"anthropic-messages">, + "claude-3.5-haiku": { + id: "claude-3.5-haiku", + name: "Claude Haiku 3.5 (latest)", + api: "anthropic-messages", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.8, + output: 4, + cacheRead: 0.08, + cacheWrite: 1, + }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"anthropic-messages">, + "claude-3.5-sonnet": { + id: "claude-3.5-sonnet", + name: "Claude Sonnet 3.5 v2", + api: "anthropic-messages", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + reasoning: false, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"anthropic-messages">, + "claude-haiku-4-5": { + id: "claude-haiku-4-5", + name: "Claude Haiku 4.5 (latest)", + api: "anthropic-messages", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 5, + cacheRead: 0.1, + cacheWrite: 1.25, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "claude-opus-4": { + id: "claude-opus-4", + name: "Claude Opus 4 (latest)", + api: "anthropic-messages", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + contextWindow: 200000, + maxTokens: 32000, + } satisfies Model<"anthropic-messages">, + "claude-opus-4-1": { + id: "claude-opus-4-1", + name: "Claude Opus 4.1 (latest)", + api: "anthropic-messages", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + contextWindow: 200000, + maxTokens: 32000, + } satisfies Model<"anthropic-messages">, + "claude-opus-4-5": { + id: "claude-opus-4-5", + name: "Claude Opus 4.5 (latest)", + api: "anthropic-messages", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + reasoning: true, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "claude-opus-4-6": { + id: "claude-opus-4-6", + name: "Claude Opus 4.6 (latest)", + api: "anthropic-messages", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"max"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "claude-opus-4-7": { + id: "claude-opus-4-7", + name: "Claude Opus 4.7", + api: "anthropic-messages", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "claude-sonnet-4": { + id: "claude-sonnet-4", + name: "Claude Sonnet 4 (latest)", + api: "anthropic-messages", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "claude-sonnet-4-5": { + id: "claude-sonnet-4-5", + name: "Claude Sonnet 4.5 (latest)", + api: "anthropic-messages", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "claude-sonnet-4-6": { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + api: "anthropic-messages", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "gpt-4": { + id: "gpt-4", + name: "GPT-4", + api: "openai-responses", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", + reasoning: false, + input: ["text"], + cost: { + input: 30, + output: 60, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 8192, + } satisfies Model<"openai-responses">, + "gpt-4-turbo": { + id: "gpt-4-turbo", + name: "GPT-4 Turbo", + api: "openai-responses", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", + reasoning: false, + input: ["text", "image"], + cost: { + input: 10, + output: 30, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-responses">, + "gpt-4o": { + id: "gpt-4o", + name: "GPT-4o", + api: "openai-responses", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2.5, + output: 10, + cacheRead: 1.25, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-responses">, + "gpt-4o-mini": { + id: "gpt-4o-mini", + name: "GPT-4o mini", + api: "openai-responses", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-responses">, + "gpt-5.1": { + id: "gpt-5.1", + name: "GPT-5.1", + api: "openai-responses", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.13, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.1-codex": { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + api: "openai-responses", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.2": { + id: "gpt-5.2", + name: "GPT-5.2", + api: "openai-responses", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.2-codex": { + id: "gpt-5.2-codex", + name: "GPT-5.2 Codex", + api: "openai-responses", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.3-codex": { + id: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + api: "openai-responses", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + api: "openai-responses", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 2.5, + output: 15, + cacheRead: 0.25, + cacheWrite: 0, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.5": { + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-responses", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 30, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "o1": { + id: "o1", + name: "o1", + api: "openai-responses", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 60, + cacheRead: 7.5, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, + "o3": { + id: "o3", + name: "o3", + api: "openai-responses", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 8, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, + "o3-mini": { + id: "o3-mini", + name: "o3-mini", + api: "openai-responses", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", + reasoning: true, + input: ["text"], + cost: { + input: 1.1, + output: 4.4, + cacheRead: 0.55, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, + "o3-pro": { + id: "o3-pro", + name: "o3-pro", + api: "openai-responses", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", + reasoning: true, + input: ["text", "image"], + cost: { + input: 20, + output: 80, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, + "o4-mini": { + id: "o4-mini", + name: "o4-mini", + api: "openai-responses", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.1, + output: 4.4, + cacheRead: 0.28, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, + "workers-ai/@cf/moonshotai/kimi-k2.5": { + id: "workers-ai/@cf/moonshotai/kimi-k2.5", + name: "Kimi K2.5", + api: "openai-completions", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/compat", + compat: {"sendSessionAffinityHeaders":true}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.6, + output: 3, + cacheRead: 0.1, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"openai-completions">, + "workers-ai/@cf/moonshotai/kimi-k2.6": { + id: "workers-ai/@cf/moonshotai/kimi-k2.6", + name: "Kimi K2.6", + api: "openai-completions", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/compat", + compat: {"sendSessionAffinityHeaders":true}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.95, + output: 4, + cacheRead: 0.16, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"openai-completions">, + "workers-ai/@cf/nvidia/nemotron-3-120b-a12b": { + id: "workers-ai/@cf/nvidia/nemotron-3-120b-a12b", + name: "Nemotron 3 Super 120B", + api: "openai-completions", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/compat", + compat: {"sendSessionAffinityHeaders":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0.5, + output: 1.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"openai-completions">, + "workers-ai/@cf/zai-org/glm-4.7-flash": { + id: "workers-ai/@cf/zai-org/glm-4.7-flash", + name: "GLM-4.7-Flash", + api: "openai-completions", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/compat", + compat: {"sendSessionAffinityHeaders":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0.06, + output: 0.4, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + }, + "cloudflare-workers-ai": { + "@cf/google/gemma-4-26b-a4b-it": { + id: "@cf/google/gemma-4-26b-a4b-it", + name: "Gemma 4 26B A4B IT", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + compat: {"sendSessionAffinityHeaders":true}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.1, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "@cf/ibm-granite/granite-4.0-h-micro": { + id: "@cf/ibm-granite/granite-4.0-h-micro", + name: "Granite 4.0 H Micro", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + compat: {"sendSessionAffinityHeaders":true}, + reasoning: false, + input: ["text"], + cost: { + input: 0.017, + output: 0.112, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131000, + maxTokens: 131000, + } satisfies Model<"openai-completions">, + "@cf/meta/llama-3.3-70b-instruct-fp8-fast": { + id: "@cf/meta/llama-3.3-70b-instruct-fp8-fast", + name: "Llama 3.3 70B Instruct fp8 Fast", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + compat: {"sendSessionAffinityHeaders":true}, + reasoning: false, + input: ["text"], + cost: { + input: 0.293, + output: 2.253, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 24000, + maxTokens: 24000, + } satisfies Model<"openai-completions">, + "@cf/meta/llama-4-scout-17b-16e-instruct": { + id: "@cf/meta/llama-4-scout-17b-16e-instruct", + name: "Llama 4 Scout 17B 16E Instruct", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + compat: {"sendSessionAffinityHeaders":true}, + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.27, + output: 0.85, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "@cf/mistralai/mistral-small-3.1-24b-instruct": { + id: "@cf/mistralai/mistral-small-3.1-24b-instruct", + name: "Mistral Small 3.1 24B Instruct", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + compat: {"sendSessionAffinityHeaders":true}, + reasoning: false, + input: ["text"], + cost: { + input: 0.351, + output: 0.555, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "@cf/moonshotai/kimi-k2.5": { + id: "@cf/moonshotai/kimi-k2.5", + name: "Kimi K2.5", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + compat: {"sendSessionAffinityHeaders":true}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.6, + output: 3, + cacheRead: 0.1, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"openai-completions">, + "@cf/moonshotai/kimi-k2.6": { + id: "@cf/moonshotai/kimi-k2.6", + name: "Kimi K2.6", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + compat: {"sendSessionAffinityHeaders":true}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.95, + output: 4, + cacheRead: 0.16, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 256000, + } satisfies Model<"openai-completions">, + "@cf/nvidia/nemotron-3-120b-a12b": { + id: "@cf/nvidia/nemotron-3-120b-a12b", + name: "Nemotron 3 Super 120B", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + compat: {"sendSessionAffinityHeaders":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0.5, + output: 1.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"openai-completions">, + "@cf/openai/gpt-oss-120b": { + id: "@cf/openai/gpt-oss-120b", + name: "GPT OSS 120B", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + compat: {"sendSessionAffinityHeaders":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0.35, + output: 0.75, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "@cf/openai/gpt-oss-20b": { + id: "@cf/openai/gpt-oss-20b", + name: "GPT OSS 20B", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + compat: {"sendSessionAffinityHeaders":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0.2, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "@cf/qwen/qwen3-30b-a3b-fp8": { + id: "@cf/qwen/qwen3-30b-a3b-fp8", + name: "Qwen3 30B A3b fp8", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + compat: {"sendSessionAffinityHeaders":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0.0509, + output: 0.335, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "@cf/zai-org/glm-4.7-flash": { + id: "@cf/zai-org/glm-4.7-flash", + name: "GLM-4.7-Flash", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + compat: {"sendSessionAffinityHeaders":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0.0605, + output: 0.4, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + }, + "deepseek": { + "deepseek-v4-flash": { + id: "deepseek-v4-flash", + name: "DeepSeek V4 Flash", + api: "openai-completions", + provider: "deepseek", + baseUrl: "https://api.deepseek.com", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, + input: ["text"], + cost: { + input: 0.14, + output: 0.28, + cacheRead: 0.0028, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 384000, + } satisfies Model<"openai-completions">, + "deepseek-v4-pro": { + id: "deepseek-v4-pro", + name: "DeepSeek V4 Pro", + api: "openai-completions", + provider: "deepseek", + baseUrl: "https://api.deepseek.com", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, + input: ["text"], + cost: { + input: 0.435, + output: 0.87, + cacheRead: 0.003625, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 384000, + } satisfies Model<"openai-completions">, + }, + "fireworks": { + "accounts/fireworks/models/deepseek-v4-flash": { + id: "accounts/fireworks/models/deepseek-v4-flash", + name: "DeepSeek V4 Flash", + api: "anthropic-messages", + provider: "fireworks", + baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0.14, + output: 0.28, + cacheRead: 0.03, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 384000, + } satisfies Model<"anthropic-messages">, + "accounts/fireworks/models/deepseek-v4-pro": { + id: "accounts/fireworks/models/deepseek-v4-pro", + name: "DeepSeek V4 Pro", + api: "anthropic-messages", + provider: "fireworks", + baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 1.74, + output: 3.48, + cacheRead: 0.145, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 384000, + } satisfies Model<"anthropic-messages">, + "accounts/fireworks/models/glm-5p1": { + id: "accounts/fireworks/models/glm-5p1", + name: "GLM 5.1", + api: "anthropic-messages", + provider: "fireworks", + baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 1.4, + output: 4.4, + cacheRead: 0.26, + cacheWrite: 0, + }, + contextWindow: 202800, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, + "accounts/fireworks/models/gpt-oss-120b": { + id: "accounts/fireworks/models/gpt-oss-120b", + name: "GPT OSS 120B", + api: "anthropic-messages", + provider: "fireworks", + baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0.015, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"anthropic-messages">, + "accounts/fireworks/models/gpt-oss-20b": { + id: "accounts/fireworks/models/gpt-oss-20b", + name: "GPT OSS 20B", + api: "anthropic-messages", + provider: "fireworks", + baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0.07, + output: 0.3, + cacheRead: 0.035, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"anthropic-messages">, + "accounts/fireworks/models/kimi-k2p5": { + id: "accounts/fireworks/models/kimi-k2p5", + name: "Kimi K2.5", + api: "anthropic-messages", + provider: "fireworks", + baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.6, + output: 3, + cacheRead: 0.1, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"anthropic-messages">, + "accounts/fireworks/models/kimi-k2p6": { + id: "accounts/fireworks/models/kimi-k2p6", + name: "Kimi K2.6", + api: "anthropic-messages", + provider: "fireworks", + baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.95, + output: 4, + cacheRead: 0.16, + cacheWrite: 0, + }, + contextWindow: 262000, + maxTokens: 262000, + } satisfies Model<"anthropic-messages">, + "accounts/fireworks/models/minimax-m2p5": { + id: "accounts/fireworks/models/minimax-m2p5", + name: "MiniMax-M2.5", + api: "anthropic-messages", + provider: "fireworks", + baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.03, + cacheWrite: 0, + }, + contextWindow: 196608, + maxTokens: 196608, + } satisfies Model<"anthropic-messages">, + "accounts/fireworks/models/minimax-m2p7": { + id: "accounts/fireworks/models/minimax-m2p7", + name: "MiniMax-M2.7", + api: "anthropic-messages", + provider: "fireworks", + baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0, + }, + contextWindow: 196608, + maxTokens: 196608, + } satisfies Model<"anthropic-messages">, + "accounts/fireworks/models/qwen3p6-plus": { + id: "accounts/fireworks/models/qwen3p6-plus", + name: "Qwen 3.6 Plus", + api: "anthropic-messages", + provider: "fireworks", + baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.5, + output: 3, + cacheRead: 0.1, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 8192, + } satisfies Model<"anthropic-messages">, + "accounts/fireworks/routers/glm-5p1-fast": { + id: "accounts/fireworks/routers/glm-5p1-fast", + name: "GLM 5.1 Fast", + api: "anthropic-messages", + provider: "fireworks", + baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 2.8, + output: 8.8, + cacheRead: 0.52, + cacheWrite: 0, + }, + contextWindow: 202800, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, + "accounts/fireworks/routers/kimi-k2p6-turbo": { + id: "accounts/fireworks/routers/kimi-k2p6-turbo", + name: "Kimi K2.6 Turbo", + api: "anthropic-messages", + provider: "fireworks", + baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 8, + cacheRead: 0.3, + cacheWrite: 0, + }, + contextWindow: 262000, + maxTokens: 262000, + } satisfies Model<"anthropic-messages">, + }, + "github-copilot": { + "claude-haiku-4.5": { + id: "claude-haiku-4.5", + name: "Claude Haiku 4.5", + api: "anthropic-messages", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsEagerToolInputStreaming":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 144000, + maxTokens: 32000, + } satisfies Model<"anthropic-messages">, + "claude-opus-4.5": { + id: "claude-opus-4.5", + name: "Claude Opus 4.5", + api: "anthropic-messages", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 160000, + maxTokens: 32000, + } satisfies Model<"anthropic-messages">, + "claude-opus-4.6": { + id: "claude-opus-4.6", + name: "Claude Opus 4.6", + api: "anthropic-messages", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"max"}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "claude-opus-4.7": { + id: "claude-opus-4.7", + name: "Claude Opus 4.7", + api: "anthropic-messages", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 144000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "claude-sonnet-4.5": { + id: "claude-sonnet-4.5", + name: "Claude Sonnet 4.5", + api: "anthropic-messages", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsEagerToolInputStreaming":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 144000, + maxTokens: 32000, + } satisfies Model<"anthropic-messages">, + "claude-sonnet-4.6": { + id: "claude-sonnet-4.6", + name: "Claude Sonnet 4.6", + api: "anthropic-messages", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 32000, + } satisfies Model<"anthropic-messages">, + "gemini-2.5-pro": { + id: "gemini-2.5-pro", + name: "Gemini 2.5 Pro", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "gemini-3-flash-preview": { + id: "gemini-3-flash-preview", + name: "Gemini 3 Flash", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "gemini-3.1-pro-preview": { + id: "gemini-3.1-pro-preview", + name: "Gemini 3.1 Pro Preview", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "gemini-3.5-flash": { + id: "gemini-3.5-flash", + name: "Gemini 3.5 Flash", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "gpt-4.1": { + id: "gpt-4.1", + name: "GPT-4.1", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "gpt-4o": { + id: "gpt-4o", + name: "GPT-4o", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "gpt-5-mini": { + id: "gpt-5-mini", + name: "GPT-5-mini", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":"low"}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 264000, + maxTokens: 64000, + } satisfies Model<"openai-responses">, + "gpt-5.2": { + id: "gpt-5.2", + name: "GPT-5.2", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":"low","xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 264000, + maxTokens: 64000, + } satisfies Model<"openai-responses">, + "gpt-5.2-codex": { + id: "gpt-5.2-codex", + name: "GPT-5.2-Codex", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":"low","xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.3-codex": { + id: "gpt-5.3-codex", + name: "GPT-5.3-Codex", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":"low","xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":"low","xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.4-mini": { + id: "gpt-5.4-mini", + name: "GPT-5.4 Mini", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":"low","xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.5": { + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":"low","xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "grok-code-fast-1": { + id: "grok-code-fast-1", + name: "Grok Code Fast 1", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + }, + "google": { + "gemini-2.0-flash": { + id: "gemini-2.0-flash", + name: "Gemini 2.0 Flash", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.1, + output: 0.4, + cacheRead: 0.025, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 8192, + } satisfies Model<"google-generative-ai">, + "gemini-2.0-flash-lite": { + id: "gemini-2.0-flash-lite", + name: "Gemini 2.0 Flash-Lite", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.075, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 8192, + } satisfies Model<"google-generative-ai">, + "gemini-2.5-flash": { + id: "gemini-2.5-flash", + name: "Gemini 2.5 Flash", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 2.5, + cacheRead: 0.03, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-2.5-flash-lite": { + id: "gemini-2.5-flash-lite", + name: "Gemini 2.5 Flash-Lite", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.1, + output: 0.4, + cacheRead: 0.01, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-2.5-pro": { + id: "gemini-2.5-pro", + name: "Gemini 2.5 Pro", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-3-flash-preview": { + id: "gemini-3-flash-preview", + name: "Gemini 3 Flash Preview", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 0.5, + output: 3, + cacheRead: 0.05, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-3-pro-preview": { + id: "gemini-3-pro-preview", + name: "Gemini 3 Pro Preview", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":"LOW","medium":null,"high":"HIGH"}, + input: ["text", "image"], + cost: { + input: 2, + output: 12, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-3.1-flash-lite": { + id: "gemini-3.1-flash-lite", + name: "Gemini 3.1 Flash Lite", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 0.25, + output: 1.5, + cacheRead: 0.025, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-3.1-flash-lite-preview": { + id: "gemini-3.1-flash-lite-preview", + name: "Gemini 3.1 Flash Lite Preview", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 0.25, + output: 1.5, + cacheRead: 0.025, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-3.1-pro-preview": { + id: "gemini-3.1-pro-preview", + name: "Gemini 3.1 Pro Preview", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":"LOW","medium":null,"high":"HIGH"}, + input: ["text", "image"], + cost: { + input: 2, + output: 12, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-3.1-pro-preview-customtools": { + id: "gemini-3.1-pro-preview-customtools", + name: "Gemini 3.1 Pro Preview Custom Tools", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":"LOW","medium":null,"high":"HIGH"}, + input: ["text", "image"], + cost: { + input: 2, + output: 12, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-3.5-flash": { + id: "gemini-3.5-flash", + name: "Gemini 3.5 Flash", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.5, + output: 9, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-flash-latest": { + id: "gemini-flash-latest", + name: "Gemini Flash Latest", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 2.5, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-flash-lite-latest": { + id: "gemini-flash-lite-latest", + name: "Gemini Flash-Lite Latest", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.1, + output: 0.4, + cacheRead: 0.025, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemma-4-26b-a4b-it": { + id: "gemma-4-26b-a4b-it", + name: "Gemma 4 26B A4B IT", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":"MINIMAL","low":null,"medium":null,"high":"HIGH"}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32768, + } satisfies Model<"google-generative-ai">, + "gemma-4-31b-it": { + id: "gemma-4-31b-it", + name: "Gemma 4 31B IT", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":"MINIMAL","low":null,"medium":null,"high":"HIGH"}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32768, + } satisfies Model<"google-generative-ai">, + }, + "google-vertex": { + "gemini-1.5-flash": { + id: "gemini-1.5-flash", + name: "Gemini 1.5 Flash (Vertex)", + api: "google-vertex", + provider: "google-vertex", + baseUrl: "https://{location}-aiplatform.googleapis.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.075, + output: 0.3, + cacheRead: 0.01875, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 8192, + } satisfies Model<"google-vertex">, + "gemini-1.5-flash-8b": { + id: "gemini-1.5-flash-8b", + name: "Gemini 1.5 Flash-8B (Vertex)", + api: "google-vertex", + provider: "google-vertex", + baseUrl: "https://{location}-aiplatform.googleapis.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.0375, + output: 0.15, + cacheRead: 0.01, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 8192, + } satisfies Model<"google-vertex">, + "gemini-1.5-pro": { + id: "gemini-1.5-pro", + name: "Gemini 1.5 Pro (Vertex)", + api: "google-vertex", + provider: "google-vertex", + baseUrl: "https://{location}-aiplatform.googleapis.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 1.25, + output: 5, + cacheRead: 0.3125, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 8192, + } satisfies Model<"google-vertex">, + "gemini-2.0-flash": { + id: "gemini-2.0-flash", + name: "Gemini 2.0 Flash (Vertex)", + api: "google-vertex", + provider: "google-vertex", + baseUrl: "https://{location}-aiplatform.googleapis.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0.0375, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 8192, + } satisfies Model<"google-vertex">, + "gemini-2.0-flash-lite": { + id: "gemini-2.0-flash-lite", + name: "Gemini 2.0 Flash Lite (Vertex)", + api: "google-vertex", + provider: "google-vertex", + baseUrl: "https://{location}-aiplatform.googleapis.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.075, + output: 0.3, + cacheRead: 0.01875, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-vertex">, + "gemini-2.5-flash": { + id: "gemini-2.5-flash", + name: "Gemini 2.5 Flash (Vertex)", + api: "google-vertex", + provider: "google-vertex", + baseUrl: "https://{location}-aiplatform.googleapis.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 2.5, + cacheRead: 0.03, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-vertex">, + "gemini-2.5-flash-lite": { + id: "gemini-2.5-flash-lite", + name: "Gemini 2.5 Flash Lite (Vertex)", + api: "google-vertex", + provider: "google-vertex", + baseUrl: "https://{location}-aiplatform.googleapis.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.1, + output: 0.4, + cacheRead: 0.01, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-vertex">, + "gemini-2.5-flash-lite-preview-09-2025": { + id: "gemini-2.5-flash-lite-preview-09-2025", + name: "Gemini 2.5 Flash Lite Preview 09-25 (Vertex)", + api: "google-vertex", + provider: "google-vertex", + baseUrl: "https://{location}-aiplatform.googleapis.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.1, + output: 0.4, + cacheRead: 0.01, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-vertex">, + "gemini-2.5-pro": { + id: "gemini-2.5-pro", + name: "Gemini 2.5 Pro (Vertex)", + api: "google-vertex", + provider: "google-vertex", + baseUrl: "https://{location}-aiplatform.googleapis.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-vertex">, + "gemini-3-flash-preview": { + id: "gemini-3-flash-preview", + name: "Gemini 3 Flash Preview (Vertex)", + api: "google-vertex", + provider: "google-vertex", + baseUrl: "https://{location}-aiplatform.googleapis.com", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 0.5, + output: 3, + cacheRead: 0.05, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-vertex">, + "gemini-3-pro-preview": { + id: "gemini-3-pro-preview", + name: "Gemini 3 Pro Preview (Vertex)", + api: "google-vertex", + provider: "google-vertex", + baseUrl: "https://{location}-aiplatform.googleapis.com", + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":"LOW","medium":null,"high":"HIGH"}, + input: ["text", "image"], + cost: { + input: 2, + output: 12, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"google-vertex">, + "gemini-3.1-pro-preview": { + id: "gemini-3.1-pro-preview", + name: "Gemini 3.1 Pro Preview (Vertex)", + api: "google-vertex", + provider: "google-vertex", + baseUrl: "https://{location}-aiplatform.googleapis.com", + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":"LOW","medium":null,"high":"HIGH"}, + input: ["text", "image"], + cost: { + input: 2, + output: 12, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-vertex">, + "gemini-3.1-pro-preview-customtools": { + id: "gemini-3.1-pro-preview-customtools", + name: "Gemini 3.1 Pro Preview Custom Tools (Vertex)", + api: "google-vertex", + provider: "google-vertex", + baseUrl: "https://{location}-aiplatform.googleapis.com", + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":"LOW","medium":null,"high":"HIGH"}, + input: ["text", "image"], + cost: { + input: 2, + output: 12, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-vertex">, + }, + "groq": { + "deepseek-r1-distill-llama-70b": { + id: "deepseek-r1-distill-llama-70b", + name: "DeepSeek R1 Distill Llama 70B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.75, + output: 0.99, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "gemma2-9b-it": { + id: "gemma2-9b-it", + name: "Gemma 2 9B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.2, + output: 0.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "groq/compound": { + id: "groq/compound", + name: "Compound", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "groq/compound-mini": { + id: "groq/compound-mini", + name: "Compound Mini", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "llama-3.1-8b-instant": { + id: "llama-3.1-8b-instant", + name: "Llama 3.1 8B Instant", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.05, + output: 0.08, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "llama-3.3-70b-versatile": { + id: "llama-3.3-70b-versatile", + name: "Llama 3.3 70B Versatile", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.59, + output: 0.79, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "llama3-70b-8192": { + id: "llama3-70b-8192", + name: "Llama 3 70B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.59, + output: 0.79, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "llama3-8b-8192": { + id: "llama3-8b-8192", + name: "Llama 3 8B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.05, + output: 0.08, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "meta-llama/llama-4-maverick-17b-128e-instruct": { + id: "meta-llama/llama-4-maverick-17b-128e-instruct", + name: "Llama 4 Maverick 17B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.2, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "meta-llama/llama-4-scout-17b-16e-instruct": { + id: "meta-llama/llama-4-scout-17b-16e-instruct", + name: "Llama 4 Scout 17B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.11, + output: 0.34, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "mistral-saba-24b": { + id: "mistral-saba-24b", + name: "Mistral Saba 24B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.79, + output: 0.79, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "moonshotai/kimi-k2-instruct": { + id: "moonshotai/kimi-k2-instruct", + name: "Kimi K2 Instruct", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1, + output: 3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "moonshotai/kimi-k2-instruct-0905": { + id: "moonshotai/kimi-k2-instruct-0905", + name: "Kimi K2 Instruct 0905", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1, + output: 3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "openai/gpt-oss-120b": { + id: "openai/gpt-oss-120b", + name: "GPT OSS 120B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "openai/gpt-oss-20b": { + id: "openai/gpt-oss-20b", + name: "GPT OSS 20B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.075, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "openai/gpt-oss-safeguard-20b": { + id: "openai/gpt-oss-safeguard-20b", + name: "Safety GPT OSS 20B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.075, + output: 0.3, + cacheRead: 0.037, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "qwen-qwq-32b": { + id: "qwen-qwq-32b", + name: "Qwen QwQ 32B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.29, + output: 0.39, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "qwen/qwen3-32b": { + id: "qwen/qwen3-32b", + name: "Qwen3 32B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"default"}, + input: ["text"], + cost: { + input: 0.29, + output: 0.59, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 40960, + } satisfies Model<"openai-completions">, + }, + "huggingface": { + "MiniMaxAI/MiniMax-M2.1": { + id: "MiniMaxAI/MiniMax-M2.1", + name: "MiniMax-M2.1", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "MiniMaxAI/MiniMax-M2.5": { + id: "MiniMaxAI/MiniMax-M2.5", + name: "MiniMax-M2.5", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.03, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "MiniMaxAI/MiniMax-M2.7": { + id: "MiniMaxAI/MiniMax-M2.7", + name: "MiniMax-M2.7", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "Qwen/Qwen3-235B-A22B-Thinking-2507": { + id: "Qwen/Qwen3-235B-A22B-Thinking-2507", + name: "Qwen3-235B-A22B-Thinking-2507", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "Qwen/Qwen3-Coder-480B-A35B-Instruct": { + id: "Qwen/Qwen3-Coder-480B-A35B-Instruct", + name: "Qwen3-Coder-480B-A35B-Instruct", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: false, + input: ["text"], + cost: { + input: 2, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 66536, + } satisfies Model<"openai-completions">, + "Qwen/Qwen3-Coder-Next": { + id: "Qwen/Qwen3-Coder-Next", + name: "Qwen3-Coder-Next", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0.2, + output: 1.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "Qwen/Qwen3-Next-80B-A3B-Instruct": { + id: "Qwen/Qwen3-Next-80B-A3B-Instruct", + name: "Qwen3-Next-80B-A3B-Instruct", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0.25, + output: 1, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 66536, + } satisfies Model<"openai-completions">, + "Qwen/Qwen3-Next-80B-A3B-Thinking": { + id: "Qwen/Qwen3-Next-80B-A3B-Thinking", + name: "Qwen3-Next-80B-A3B-Thinking", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0.3, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "Qwen/Qwen3.5-397B-A17B": { + id: "Qwen/Qwen3.5-397B-A17B", + name: "Qwen3.5-397B-A17B", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.6, + output: 3.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "XiaomiMiMo/MiMo-V2-Flash": { + id: "XiaomiMiMo/MiMo-V2-Flash", + name: "MiMo-V2-Flash", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0.1, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "deepseek-ai/DeepSeek-R1-0528": { + id: "deepseek-ai/DeepSeek-R1-0528", + name: "DeepSeek-R1-0528", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: true, + input: ["text"], + cost: { + input: 3, + output: 5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 163840, + } satisfies Model<"openai-completions">, + "deepseek-ai/DeepSeek-V3.2": { + id: "deepseek-ai/DeepSeek-V3.2", + name: "DeepSeek-V3.2", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0.28, + output: 0.4, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "deepseek-ai/DeepSeek-V4-Pro": { + id: "deepseek-ai/DeepSeek-V4-Pro", + name: "DeepSeek V4 Pro", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: true, + input: ["text"], + cost: { + input: 1.74, + output: 3.48, + cacheRead: 0.145, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 393216, + } satisfies Model<"openai-completions">, + "moonshotai/Kimi-K2-Instruct": { + id: "moonshotai/Kimi-K2-Instruct", + name: "Kimi-K2-Instruct", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: false, + input: ["text"], + cost: { + input: 1, + output: 3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "moonshotai/Kimi-K2-Instruct-0905": { + id: "moonshotai/Kimi-K2-Instruct-0905", + name: "Kimi-K2-Instruct-0905", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: false, + input: ["text"], + cost: { + input: 1, + output: 3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "moonshotai/Kimi-K2-Thinking": { + id: "moonshotai/Kimi-K2-Thinking", + name: "Kimi-K2-Thinking", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0.6, + output: 2.5, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "moonshotai/Kimi-K2.5": { + id: "moonshotai/Kimi-K2.5", + name: "Kimi-K2.5", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.6, + output: 3, + cacheRead: 0.1, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "moonshotai/Kimi-K2.6": { + id: "moonshotai/Kimi-K2.6", + name: "Kimi-K2.6", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.95, + output: 4, + cacheRead: 0.16, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "zai-org/GLM-4.7": { + id: "zai-org/GLM-4.7", + name: "GLM-4.7", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0.6, + output: 2.2, + cacheRead: 0.11, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "zai-org/GLM-4.7-Flash": { + id: "zai-org/GLM-4.7-Flash", + name: "GLM-4.7-Flash", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "zai-org/GLM-5": { + id: "zai-org/GLM-5", + name: "GLM-5", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: true, + input: ["text"], + cost: { + input: 1, + output: 3.2, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 202752, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "zai-org/GLM-5.1": { + id: "zai-org/GLM-5.1", + name: "GLM-5.1", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: true, + input: ["text"], + cost: { + input: 1, + output: 3.2, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 202752, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + }, + "kimi-coding": { + "kimi-for-coding": { + id: "kimi-for-coding", + name: "Kimi For Coding", + api: "anthropic-messages", + provider: "kimi-coding", + baseUrl: "https://api.kimi.com/coding", + headers: {"User-Agent":"KimiCLI/1.5"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32768, + } satisfies Model<"anthropic-messages">, + "kimi-k2-thinking": { + id: "kimi-k2-thinking", + name: "Kimi K2 Thinking", + api: "anthropic-messages", + provider: "kimi-coding", + baseUrl: "https://api.kimi.com/coding", + headers: {"User-Agent":"KimiCLI/1.5"}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32768, + } satisfies Model<"anthropic-messages">, + }, + "minimax": { + "MiniMax-M2.7": { + id: "MiniMax-M2.7", + name: "MiniMax-M2.7", + api: "anthropic-messages", + provider: "minimax", + baseUrl: "https://api.minimax.io/anthropic", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0.375, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, + "MiniMax-M2.7-highspeed": { + id: "MiniMax-M2.7-highspeed", + name: "MiniMax-M2.7-highspeed", + api: "anthropic-messages", + provider: "minimax", + baseUrl: "https://api.minimax.io/anthropic", + reasoning: true, + input: ["text"], + cost: { + input: 0.6, + output: 2.4, + cacheRead: 0.06, + cacheWrite: 0.375, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, + }, + "minimax-cn": { + "MiniMax-M2.7": { + id: "MiniMax-M2.7", + name: "MiniMax-M2.7", + api: "anthropic-messages", + provider: "minimax-cn", + baseUrl: "https://api.minimaxi.com/anthropic", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0.375, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, + "MiniMax-M2.7-highspeed": { + id: "MiniMax-M2.7-highspeed", + name: "MiniMax-M2.7-highspeed", + api: "anthropic-messages", + provider: "minimax-cn", + baseUrl: "https://api.minimaxi.com/anthropic", + reasoning: true, + input: ["text"], + cost: { + input: 0.6, + output: 2.4, + cacheRead: 0.06, + cacheWrite: 0.375, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, + }, + "mistral": { + "codestral-latest": { + id: "codestral-latest", + name: "Codestral (latest)", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: false, + input: ["text"], + cost: { + input: 0.3, + output: 0.9, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 4096, + } satisfies Model<"mistral-conversations">, + "devstral-2512": { + id: "devstral-2512", + name: "Devstral 2", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: false, + input: ["text"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"mistral-conversations">, + "devstral-medium-2507": { + id: "devstral-medium-2507", + name: "Devstral Medium", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: false, + input: ["text"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"mistral-conversations">, + "devstral-medium-latest": { + id: "devstral-medium-latest", + name: "Devstral 2 (latest)", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: false, + input: ["text"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"mistral-conversations">, + "devstral-small-2505": { + id: "devstral-small-2505", + name: "Devstral Small 2505", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: false, + input: ["text"], + cost: { + input: 0.1, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"mistral-conversations">, + "devstral-small-2507": { + id: "devstral-small-2507", + name: "Devstral Small", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: false, + input: ["text"], + cost: { + input: 0.1, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"mistral-conversations">, + "labs-devstral-small-2512": { + id: "labs-devstral-small-2512", + name: "Devstral Small 2", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"mistral-conversations">, + "magistral-medium-latest": { + id: "magistral-medium-latest", + name: "Magistral Medium (latest)", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: true, + input: ["text"], + cost: { + input: 2, + output: 5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"mistral-conversations">, + "magistral-small": { + id: "magistral-small", + name: "Magistral Small", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: true, + input: ["text"], + cost: { + input: 0.5, + output: 1.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"mistral-conversations">, + "ministral-3b-latest": { + id: "ministral-3b-latest", + name: "Ministral 3B (latest)", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: false, + input: ["text"], + cost: { + input: 0.04, + output: 0.04, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"mistral-conversations">, + "ministral-8b-latest": { + id: "ministral-8b-latest", + name: "Ministral 8B (latest)", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: false, + input: ["text"], + cost: { + input: 0.1, + output: 0.1, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"mistral-conversations">, + "mistral-large-2411": { + id: "mistral-large-2411", + name: "Mistral Large 2.1", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: false, + input: ["text"], + cost: { + input: 2, + output: 6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"mistral-conversations">, + "mistral-large-2512": { + id: "mistral-large-2512", + name: "Mistral Large 3", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.5, + output: 1.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"mistral-conversations">, + "mistral-large-latest": { + id: "mistral-large-latest", + name: "Mistral Large (latest)", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.5, + output: 1.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"mistral-conversations">, + "mistral-medium-2505": { + id: "mistral-medium-2505", + name: "Mistral Medium 3", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"mistral-conversations">, + "mistral-medium-2508": { + id: "mistral-medium-2508", + name: "Mistral Medium 3.1", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"mistral-conversations">, + "mistral-medium-2604": { + id: "mistral-medium-2604", + name: "Mistral Medium 3.5", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.5, + output: 7.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"mistral-conversations">, + "mistral-medium-3.5": { + id: "mistral-medium-3.5", + name: "Mistral Medium 3.5", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.5, + output: 7.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"mistral-conversations">, + "mistral-medium-latest": { + id: "mistral-medium-latest", + name: "Mistral Medium (latest)", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.5, + output: 7.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"mistral-conversations">, + "mistral-nemo": { + id: "mistral-nemo", + name: "Mistral Nemo", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: false, + input: ["text"], + cost: { + input: 0.15, + output: 0.15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"mistral-conversations">, + "mistral-small-2506": { + id: "mistral-small-2506", + name: "Mistral Small 3.2", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.1, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"mistral-conversations">, + "mistral-small-2603": { + id: "mistral-small-2603", + name: "Mistral Small 4", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"mistral-conversations">, + "mistral-small-latest": { + id: "mistral-small-latest", + name: "Mistral Small (latest)", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"mistral-conversations">, + "open-mistral-7b": { + id: "open-mistral-7b", + name: "Mistral 7B", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: false, + input: ["text"], + cost: { + input: 0.25, + output: 0.25, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8000, + maxTokens: 8000, + } satisfies Model<"mistral-conversations">, + "open-mixtral-8x22b": { + id: "open-mixtral-8x22b", + name: "Mixtral 8x22B", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: false, + input: ["text"], + cost: { + input: 2, + output: 6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 64000, + maxTokens: 64000, + } satisfies Model<"mistral-conversations">, + "open-mixtral-8x7b": { + id: "open-mixtral-8x7b", + name: "Mixtral 8x7B", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: false, + input: ["text"], + cost: { + input: 0.7, + output: 0.7, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32000, + maxTokens: 32000, + } satisfies Model<"mistral-conversations">, + "pixtral-12b": { + id: "pixtral-12b", + name: "Pixtral 12B", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"mistral-conversations">, + "pixtral-large-latest": { + id: "pixtral-large-latest", + name: "Pixtral Large (latest)", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2, + output: 6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"mistral-conversations">, + }, + "moonshotai": { + "kimi-k2-0711-preview": { + id: "kimi-k2-0711-preview", + name: "Kimi K2 0711", + api: "openai-completions", + provider: "moonshotai", + baseUrl: "https://api.moonshot.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0.6, + output: 2.5, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "kimi-k2-0905-preview": { + id: "kimi-k2-0905-preview", + name: "Kimi K2 0905", + api: "openai-completions", + provider: "moonshotai", + baseUrl: "https://api.moonshot.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0.6, + output: 2.5, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "kimi-k2-thinking": { + id: "kimi-k2-thinking", + name: "Kimi K2 Thinking", + api: "openai-completions", + provider: "moonshotai", + baseUrl: "https://api.moonshot.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0.6, + output: 2.5, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "kimi-k2-thinking-turbo": { + id: "kimi-k2-thinking-turbo", + name: "Kimi K2 Thinking Turbo", + api: "openai-completions", + provider: "moonshotai", + baseUrl: "https://api.moonshot.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: true, + input: ["text"], + cost: { + input: 1.15, + output: 8, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "kimi-k2-turbo-preview": { + id: "kimi-k2-turbo-preview", + name: "Kimi K2 Turbo", + api: "openai-completions", + provider: "moonshotai", + baseUrl: "https://api.moonshot.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: false, + input: ["text"], + cost: { + input: 2.4, + output: 10, + cacheRead: 0.6, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "kimi-k2.5": { + id: "kimi-k2.5", + name: "Kimi K2.5", + api: "openai-completions", + provider: "moonshotai", + baseUrl: "https://api.moonshot.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.6, + output: 3, + cacheRead: 0.1, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "kimi-k2.6": { + id: "kimi-k2.6", + name: "Kimi K2.6", + api: "openai-completions", + provider: "moonshotai", + baseUrl: "https://api.moonshot.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.95, + output: 4, + cacheRead: 0.16, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + }, + "moonshotai-cn": { + "kimi-k2-0711-preview": { + id: "kimi-k2-0711-preview", + name: "Kimi K2 0711", + api: "openai-completions", + provider: "moonshotai-cn", + baseUrl: "https://api.moonshot.cn/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0.6, + output: 2.5, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "kimi-k2-0905-preview": { + id: "kimi-k2-0905-preview", + name: "Kimi K2 0905", + api: "openai-completions", + provider: "moonshotai-cn", + baseUrl: "https://api.moonshot.cn/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0.6, + output: 2.5, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "kimi-k2-thinking": { + id: "kimi-k2-thinking", + name: "Kimi K2 Thinking", + api: "openai-completions", + provider: "moonshotai-cn", + baseUrl: "https://api.moonshot.cn/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0.6, + output: 2.5, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "kimi-k2-thinking-turbo": { + id: "kimi-k2-thinking-turbo", + name: "Kimi K2 Thinking Turbo", + api: "openai-completions", + provider: "moonshotai-cn", + baseUrl: "https://api.moonshot.cn/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: true, + input: ["text"], + cost: { + input: 1.15, + output: 8, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "kimi-k2-turbo-preview": { + id: "kimi-k2-turbo-preview", + name: "Kimi K2 Turbo", + api: "openai-completions", + provider: "moonshotai-cn", + baseUrl: "https://api.moonshot.cn/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: false, + input: ["text"], + cost: { + input: 2.4, + output: 10, + cacheRead: 0.6, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "kimi-k2.5": { + id: "kimi-k2.5", + name: "Kimi K2.5", + api: "openai-completions", + provider: "moonshotai-cn", + baseUrl: "https://api.moonshot.cn/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.6, + output: 3, + cacheRead: 0.1, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "kimi-k2.6": { + id: "kimi-k2.6", + name: "Kimi K2.6", + api: "openai-completions", + provider: "moonshotai-cn", + baseUrl: "https://api.moonshot.cn/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.95, + output: 4, + cacheRead: 0.16, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + }, + "openai": { + "gpt-4": { + id: "gpt-4", + name: "GPT-4", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + input: ["text"], + cost: { + input: 30, + output: 60, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 8192, + } satisfies Model<"openai-responses">, + "gpt-4-turbo": { + id: "gpt-4-turbo", + name: "GPT-4 Turbo", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 10, + output: 30, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-responses">, + "gpt-4.1": { + id: "gpt-4.1", + name: "GPT-4.1", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2, + output: 8, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 1047576, + maxTokens: 32768, + } satisfies Model<"openai-responses">, + "gpt-4.1-mini": { + id: "gpt-4.1-mini", + name: "GPT-4.1 mini", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.4, + output: 1.6, + cacheRead: 0.1, + cacheWrite: 0, + }, + contextWindow: 1047576, + maxTokens: 32768, + } satisfies Model<"openai-responses">, + "gpt-4.1-nano": { + id: "gpt-4.1-nano", + name: "GPT-4.1 nano", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.1, + output: 0.4, + cacheRead: 0.03, + cacheWrite: 0, + }, + contextWindow: 1047576, + maxTokens: 32768, + } satisfies Model<"openai-responses">, + "gpt-4o": { + id: "gpt-4o", + name: "GPT-4o", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2.5, + output: 10, + cacheRead: 1.25, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-responses">, + "gpt-4o-2024-05-13": { + id: "gpt-4o-2024-05-13", + name: "GPT-4o (2024-05-13)", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 5, + output: 15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-responses">, + "gpt-4o-2024-08-06": { + id: "gpt-4o-2024-08-06", + name: "GPT-4o (2024-08-06)", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2.5, + output: 10, + cacheRead: 1.25, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-responses">, + "gpt-4o-2024-11-20": { + id: "gpt-4o-2024-11-20", + name: "GPT-4o (2024-11-20)", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2.5, + output: 10, + cacheRead: 1.25, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-responses">, + "gpt-4o-mini": { + id: "gpt-4o-mini", + name: "GPT-4o mini", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-responses">, + "gpt-5": { + id: "gpt-5", + name: "GPT-5", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5-chat-latest": { + id: "gpt-5-chat-latest", + name: "GPT-5 Chat Latest", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-responses">, + "gpt-5-codex": { + id: "gpt-5-codex", + name: "GPT-5-Codex", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5-mini": { + id: "gpt-5-mini", + name: "GPT-5 Mini", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 0.25, + output: 2, + cacheRead: 0.025, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5-nano": { + id: "gpt-5-nano", + name: "GPT-5 Nano", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 0.05, + output: 0.4, + cacheRead: 0.005, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5-pro": { + id: "gpt-5-pro", + name: "GPT-5 Pro", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 15, + output: 120, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 272000, + } satisfies Model<"openai-responses">, + "gpt-5.1": { + id: "gpt-5.1", + name: "GPT-5.1", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + thinkingLevelMap: {"off":"none"}, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.13, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.1-chat-latest": { + id: "gpt-5.1-chat-latest", + name: "GPT-5.1 Chat", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-responses">, + "gpt-5.1-codex": { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.1-codex-max": { + id: "gpt-5.1-codex-max", + name: "GPT-5.1 Codex Max", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.1-codex-mini": { + id: "gpt-5.1-codex-mini", + name: "GPT-5.1 Codex mini", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 0.25, + output: 2, + cacheRead: 0.025, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.2": { + id: "gpt-5.2", + name: "GPT-5.2", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + thinkingLevelMap: {"off":"none","xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.2-chat-latest": { + id: "gpt-5.2-chat-latest", + name: "GPT-5.2 Chat", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-responses">, + "gpt-5.2-codex": { + id: "gpt-5.2-codex", + name: "GPT-5.2 Codex", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.2-pro": { + id: "gpt-5.2-pro", + name: "GPT-5.2 Pro", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 21, + output: 168, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.3-chat-latest": { + id: "gpt-5.3-chat-latest", + name: "GPT-5.3 Chat (latest)", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-responses">, + "gpt-5.3-codex": { + id: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + thinkingLevelMap: {"off":"none","xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.3-codex-spark": { + id: "gpt-5.3-codex-spark", + name: "GPT-5.3 Codex Spark", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 32000, + } satisfies Model<"openai-responses">, + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + thinkingLevelMap: {"off":"none","xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 2.5, + output: 15, + cacheRead: 0.25, + cacheWrite: 0, + }, + contextWindow: 272000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.4-mini": { + id: "gpt-5.4-mini", + name: "GPT-5.4 mini", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + thinkingLevelMap: {"off":"none","xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 0.75, + output: 4.5, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.4-nano": { + id: "gpt-5.4-nano", + name: "GPT-5.4 nano", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + thinkingLevelMap: {"off":"none","xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 0.2, + output: 1.25, + cacheRead: 0.02, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.4-pro": { + id: "gpt-5.4-pro", + name: "GPT-5.4 Pro", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 30, + output: 180, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.5": { + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + thinkingLevelMap: {"off":"none","xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 30, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 272000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.5-pro": { + id: "gpt-5.5-pro", + name: "GPT-5.5 Pro", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 30, + output: 180, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "o1": { + id: "o1", + name: "o1", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 60, + cacheRead: 7.5, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, + "o1-pro": { + id: "o1-pro", + name: "o1-pro", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 150, + output: 600, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, + "o3": { + id: "o3", + name: "o3", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 8, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, + "o3-deep-research": { + id: "o3-deep-research", + name: "o3-deep-research", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 10, + output: 40, + cacheRead: 2.5, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, + "o3-mini": { + id: "o3-mini", + name: "o3-mini", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text"], + cost: { + input: 1.1, + output: 4.4, + cacheRead: 0.55, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, + "o3-pro": { + id: "o3-pro", + name: "o3-pro", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 20, + output: 80, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, + "o4-mini": { + id: "o4-mini", + name: "o4-mini", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.1, + output: 4.4, + cacheRead: 0.28, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, + "o4-mini-deep-research": { + id: "o4-mini-deep-research", + name: "o4-mini-deep-research", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 8, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, + }, + "openai-codex": { + "gpt-5.2": { + id: "gpt-5.2", + name: "GPT-5.2", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh","minimal":"low"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 272000, + maxTokens: 128000, + } satisfies Model<"openai-codex-responses">, + "gpt-5.3-codex": { + id: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh","minimal":"low"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 272000, + maxTokens: 128000, + } satisfies Model<"openai-codex-responses">, + "gpt-5.3-codex-spark": { + id: "gpt-5.3-codex-spark", + name: "GPT-5.3 Codex Spark", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh","minimal":"low"}, + input: ["text"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 272000, + maxTokens: 128000, + } satisfies Model<"openai-codex-responses">, + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh","minimal":"low"}, + input: ["text", "image"], + cost: { + input: 2.5, + output: 15, + cacheRead: 0.25, + cacheWrite: 0, + }, + contextWindow: 272000, + maxTokens: 128000, + } satisfies Model<"openai-codex-responses">, + "gpt-5.4-mini": { + id: "gpt-5.4-mini", + name: "GPT-5.4 mini", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh","minimal":"low"}, + input: ["text", "image"], + cost: { + input: 0.75, + output: 4.5, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 272000, + maxTokens: 128000, + } satisfies Model<"openai-codex-responses">, + "gpt-5.5": { + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh","minimal":"low"}, + input: ["text", "image"], + cost: { + input: 5, + output: 30, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 272000, + maxTokens: 128000, + } satisfies Model<"openai-codex-responses">, + }, + "opencode": { + "big-pickle": { + id: "big-pickle", + name: "Big Pickle", + api: "openai-completions", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "claude-haiku-4-5": { + id: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + api: "anthropic-messages", + provider: "opencode", + baseUrl: "https://opencode.ai/zen", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 5, + cacheRead: 0.1, + cacheWrite: 1.25, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "claude-opus-4-1": { + id: "claude-opus-4-1", + name: "Claude Opus 4.1", + api: "anthropic-messages", + provider: "opencode", + baseUrl: "https://opencode.ai/zen", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + contextWindow: 200000, + maxTokens: 32000, + } satisfies Model<"anthropic-messages">, + "claude-opus-4-5": { + id: "claude-opus-4-5", + name: "Claude Opus 4.5", + api: "anthropic-messages", + provider: "opencode", + baseUrl: "https://opencode.ai/zen", + reasoning: true, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "claude-opus-4-6": { + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + api: "anthropic-messages", + provider: "opencode", + baseUrl: "https://opencode.ai/zen", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"max"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "claude-opus-4-7": { + id: "claude-opus-4-7", + name: "Claude Opus 4.7", + api: "anthropic-messages", + provider: "opencode", + baseUrl: "https://opencode.ai/zen", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "claude-sonnet-4": { + id: "claude-sonnet-4", + name: "Claude Sonnet 4", + api: "anthropic-messages", + provider: "opencode", + baseUrl: "https://opencode.ai/zen", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "claude-sonnet-4-5": { + id: "claude-sonnet-4-5", + name: "Claude Sonnet 4.5", + api: "anthropic-messages", + provider: "opencode", + baseUrl: "https://opencode.ai/zen", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "claude-sonnet-4-6": { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + api: "anthropic-messages", + provider: "opencode", + baseUrl: "https://opencode.ai/zen", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "deepseek-v4-flash-free": { + id: "deepseek-v4-flash-free", + name: "DeepSeek V4 Flash Free", + api: "openai-completions", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "gemini-3-flash": { + id: "gemini-3-flash", + name: "Gemini 3 Flash", + api: "google-generative-ai", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 0.5, + output: 3, + cacheRead: 0.05, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-3.1-pro": { + id: "gemini-3.1-pro", + name: "Gemini 3.1 Pro Preview", + api: "google-generative-ai", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":"LOW","medium":null,"high":"HIGH"}, + input: ["text", "image"], + cost: { + input: 2, + output: 12, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-3.5-flash": { + id: "gemini-3.5-flash", + name: "Gemini 3.5 Flash", + api: "google-generative-ai", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.5, + output: 9, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "glm-5": { + id: "glm-5", + name: "GLM-5", + api: "openai-completions", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + input: ["text"], + cost: { + input: 1, + output: 3.2, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "glm-5.1": { + id: "glm-5.1", + name: "GLM-5.1", + api: "openai-completions", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + input: ["text"], + cost: { + input: 1.4, + output: 4.4, + cacheRead: 0.26, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "gpt-5": { + id: "gpt-5", + name: "GPT-5", + api: "openai-responses", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.07, + output: 8.5, + cacheRead: 0.107, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5-codex": { + id: "gpt-5-codex", + name: "GPT-5 Codex", + api: "openai-responses", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.07, + output: 8.5, + cacheRead: 0.107, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5-nano": { + id: "gpt-5-nano", + name: "GPT-5 Nano", + api: "openai-responses", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 0.05, + output: 0.4, + cacheRead: 0.005, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.1": { + id: "gpt-5.1", + name: "GPT-5.1", + api: "openai-responses", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.07, + output: 8.5, + cacheRead: 0.107, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.1-codex": { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + api: "openai-responses", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.07, + output: 8.5, + cacheRead: 0.107, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.1-codex-max": { + id: "gpt-5.1-codex-max", + name: "GPT-5.1 Codex Max", + api: "openai-responses", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.1-codex-mini": { + id: "gpt-5.1-codex-mini", + name: "GPT-5.1 Codex Mini", + api: "openai-responses", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 0.25, + output: 2, + cacheRead: 0.025, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.2": { + id: "gpt-5.2", + name: "GPT-5.2", + api: "openai-responses", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.2-codex": { + id: "gpt-5.2-codex", + name: "GPT-5.2 Codex", + api: "openai-responses", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.3-codex": { + id: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + api: "openai-responses", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + api: "openai-responses", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 2.5, + output: 15, + cacheRead: 0.25, + cacheWrite: 0, + }, + contextWindow: 272000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.4-mini": { + id: "gpt-5.4-mini", + name: "GPT-5.4 Mini", + api: "openai-responses", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 0.75, + output: 4.5, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.4-nano": { + id: "gpt-5.4-nano", + name: "GPT-5.4 Nano", + api: "openai-responses", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 0.2, + output: 1.25, + cacheRead: 0.02, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.4-pro": { + id: "gpt-5.4-pro", + name: "GPT-5.4 Pro", + api: "openai-responses", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 30, + output: 180, + cacheRead: 30, + cacheWrite: 0, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.5": { + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-responses", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 30, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.5-pro": { + id: "gpt-5.5-pro", + name: "GPT-5.5 Pro", + api: "openai-responses", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 30, + output: 180, + cacheRead: 30, + cacheWrite: 0, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "grok-build-0.1": { + id: "grok-build-0.1", + name: "Grok Build 0.1", + api: "openai-completions", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 2, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"openai-completions">, + "kimi-k2.5": { + id: "kimi-k2.5", + name: "Kimi K2.5", + api: "openai-completions", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.6, + output: 3, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "kimi-k2.6": { + id: "kimi-k2.6", + name: "Kimi K2.6", + api: "openai-completions", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.95, + output: 4, + cacheRead: 0.16, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "minimax-m2.5": { + id: "minimax-m2.5", + name: "MiniMax M2.5", + api: "openai-completions", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "minimax-m2.7": { + id: "minimax-m2.7", + name: "MiniMax M2.7", + api: "openai-completions", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "nemotron-3-super-free": { + id: "nemotron-3-super-free", + name: "Nemotron 3 Super Free", + api: "openai-completions", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "qwen3.5-plus": { + id: "qwen3.5-plus", + name: "Qwen3.5 Plus", + api: "anthropic-messages", + provider: "opencode", + baseUrl: "https://opencode.ai/zen", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.2, + output: 1.2, + cacheRead: 0.02, + cacheWrite: 0.25, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"anthropic-messages">, + "qwen3.6-plus": { + id: "qwen3.6-plus", + name: "Qwen3.6 Plus", + api: "anthropic-messages", + provider: "opencode", + baseUrl: "https://opencode.ai/zen", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.5, + output: 3, + cacheRead: 0.05, + cacheWrite: 0.625, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"anthropic-messages">, + }, + "opencode-go": { + "deepseek-v4-flash": { + id: "deepseek-v4-flash", + name: "DeepSeek V4 Flash", + api: "openai-completions", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, + input: ["text"], + cost: { + input: 0.14, + output: 0.28, + cacheRead: 0.0028, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 384000, + } satisfies Model<"openai-completions">, + "deepseek-v4-pro": { + id: "deepseek-v4-pro", + name: "DeepSeek V4 Pro", + api: "openai-completions", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, + input: ["text"], + cost: { + input: 1.74, + output: 3.48, + cacheRead: 0.0145, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 384000, + } satisfies Model<"openai-completions">, + "glm-5": { + id: "glm-5", + name: "GLM-5", + api: "openai-completions", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + reasoning: true, + input: ["text"], + cost: { + input: 1, + output: 3.2, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 202752, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "glm-5.1": { + id: "glm-5.1", + name: "GLM-5.1", + api: "openai-completions", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + reasoning: true, + input: ["text"], + cost: { + input: 1.4, + output: 4.4, + cacheRead: 0.26, + cacheWrite: 0, + }, + contextWindow: 202752, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "kimi-k2.5": { + id: "kimi-k2.5", + name: "Kimi K2.5", + api: "openai-completions", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.6, + output: 3, + cacheRead: 0.1, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "kimi-k2.6": { + id: "kimi-k2.6", + name: "Kimi K2.6", + api: "openai-completions", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.95, + output: 4, + cacheRead: 0.16, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "mimo-v2.5": { + id: "mimo-v2.5", + name: "MiMo V2.5", + api: "openai-completions", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "mimo-v2.5-pro": { + id: "mimo-v2.5-pro", + name: "MiMo V2.5 Pro", + api: "openai-completions", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + reasoning: true, + input: ["text"], + cost: { + input: 1, + output: 3, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "minimax-m2.5": { + id: "minimax-m2.5", + name: "MiniMax M2.5", + api: "anthropic-messages", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.03, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 65536, + } satisfies Model<"anthropic-messages">, + "minimax-m2.7": { + id: "minimax-m2.7", + name: "MiniMax M2.7", + api: "openai-completions", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "qwen3.5-plus": { + id: "qwen3.5-plus", + name: "Qwen3.5 Plus", + api: "openai-completions", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"thinkingFormat":"qwen"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.2, + output: 1.2, + cacheRead: 0.02, + cacheWrite: 0.25, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "qwen3.6-plus": { + id: "qwen3.6-plus", + name: "Qwen3.6 Plus", + api: "openai-completions", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"thinkingFormat":"qwen"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.5, + output: 3, + cacheRead: 0.05, + cacheWrite: 0.625, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + }, + "openrouter": { + "ai21/jamba-large-1.7": { + id: "ai21/jamba-large-1.7", + name: "AI21: Jamba Large 1.7", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2, + output: 8, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "alibaba/tongyi-deepresearch-30b-a3b": { + id: "alibaba/tongyi-deepresearch-30b-a3b", + name: "Tongyi DeepResearch 30B A3B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.09, + output: 0.44999999999999996, + cacheRead: 0.09, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "amazon/nova-2-lite-v1": { + id: "amazon/nova-2-lite-v1", + name: "Amazon: Nova 2 Lite", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 2.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 65535, + } satisfies Model<"openai-completions">, + "amazon/nova-lite-v1": { + id: "amazon/nova-lite-v1", + name: "Amazon: Nova Lite 1.0", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.06, + output: 0.24, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 300000, + maxTokens: 5120, + } satisfies Model<"openai-completions">, + "amazon/nova-micro-v1": { + id: "amazon/nova-micro-v1", + name: "Amazon: Nova Micro 1.0", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.035, + output: 0.14, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 5120, + } satisfies Model<"openai-completions">, + "amazon/nova-premier-v1": { + id: "amazon/nova-premier-v1", + name: "Amazon: Nova Premier 1.0", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2.5, + output: 12.5, + cacheRead: 0.625, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 32000, + } satisfies Model<"openai-completions">, + "amazon/nova-pro-v1": { + id: "amazon/nova-pro-v1", + name: "Amazon: Nova Pro 1.0", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.7999999999999999, + output: 3.1999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 300000, + maxTokens: 5120, + } satisfies Model<"openai-completions">, + "anthropic/claude-3-haiku": { + id: "anthropic/claude-3-haiku", + name: "Anthropic: Claude 3 Haiku", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.25, + output: 1.25, + cacheRead: 0.03, + cacheWrite: 0.3, + }, + contextWindow: 200000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "anthropic/claude-3.5-haiku": { + id: "anthropic/claude-3.5-haiku", + name: "Anthropic: Claude 3.5 Haiku", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.7999999999999999, + output: 4, + cacheRead: 0.08, + cacheWrite: 1, + }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "anthropic/claude-haiku-4.5": { + id: "anthropic/claude-haiku-4.5", + name: "Anthropic: Claude Haiku 4.5", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 5, + cacheRead: 0.09999999999999999, + cacheWrite: 1.25, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "anthropic/claude-opus-4": { + id: "anthropic/claude-opus-4", + name: "Anthropic: Claude Opus 4", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + contextWindow: 200000, + maxTokens: 32000, + } satisfies Model<"openai-completions">, + "anthropic/claude-opus-4.1": { + id: "anthropic/claude-opus-4.1", + name: "Anthropic: Claude Opus 4.1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + contextWindow: 200000, + maxTokens: 32000, + } satisfies Model<"openai-completions">, + "anthropic/claude-opus-4.5": { + id: "anthropic/claude-opus-4.5", + name: "Anthropic: Claude Opus 4.5", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "anthropic/claude-opus-4.6": { + id: "anthropic/claude-opus-4.6", + name: "Anthropic: Claude Opus 4.6", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"xhigh":"max"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "anthropic/claude-opus-4.6-fast": { + id: "anthropic/claude-opus-4.6-fast", + name: "Anthropic: Claude Opus 4.6 (Fast)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"xhigh":"max"}, + input: ["text", "image"], + cost: { + input: 30, + output: 150, + cacheRead: 3, + cacheWrite: 37.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "anthropic/claude-opus-4.7": { + id: "anthropic/claude-opus-4.7", + name: "Anthropic: Claude Opus 4.7", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "anthropic/claude-opus-4.7-fast": { + id: "anthropic/claude-opus-4.7-fast", + name: "Anthropic: Claude Opus 4.7 (Fast)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 30, + output: 150, + cacheRead: 3, + cacheWrite: 37.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "anthropic/claude-sonnet-4": { + id: "anthropic/claude-sonnet-4", + name: "Anthropic: Claude Sonnet 4", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "anthropic/claude-sonnet-4.5": { + id: "anthropic/claude-sonnet-4.5", + name: "Anthropic: Claude Sonnet 4.5", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "anthropic/claude-sonnet-4.6": { + id: "anthropic/claude-sonnet-4.6", + name: "Anthropic: Claude Sonnet 4.6", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "arcee-ai/trinity-large-thinking": { + id: "arcee-ai/trinity-large-thinking", + name: "Arcee AI: Trinity Large Thinking", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.22, + output: 0.85, + cacheRead: 0.06, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "arcee-ai/trinity-large-thinking:free": { + id: "arcee-ai/trinity-large-thinking:free", + name: "Arcee AI: Trinity Large Thinking (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 80000, + } satisfies Model<"openai-completions">, + "arcee-ai/trinity-mini": { + id: "arcee-ai/trinity-mini", + name: "Arcee AI: Trinity Mini", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.045, + output: 0.15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "arcee-ai/virtuoso-large": { + id: "arcee-ai/virtuoso-large", + name: "Arcee AI: Virtuoso Large", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.75, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "auto": { + id: "auto", + name: "Auto", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 2000000, + maxTokens: 30000, + } satisfies Model<"openai-completions">, + "baidu/cobuddy:free": { + id: "baidu/cobuddy:free", + name: "Baidu Qianfan: CoBuddy (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "baidu/ernie-4.5-21b-a3b": { + id: "baidu/ernie-4.5-21b-a3b", + name: "Baidu: ERNIE 4.5 21B A3B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.07, + output: 0.28, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8000, + } satisfies Model<"openai-completions">, + "baidu/ernie-4.5-vl-28b-a3b": { + id: "baidu/ernie-4.5-vl-28b-a3b", + name: "Baidu: ERNIE 4.5 VL 28B A3B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.14, + output: 0.56, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8000, + } satisfies Model<"openai-completions">, + "bytedance-seed/seed-1.6": { + id: "bytedance-seed/seed-1.6", + name: "ByteDance Seed: Seed 1.6", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.25, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "bytedance-seed/seed-1.6-flash": { + id: "bytedance-seed/seed-1.6-flash", + name: "ByteDance Seed: Seed 1.6 Flash", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.075, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "bytedance-seed/seed-2.0-lite": { + id: "bytedance-seed/seed-2.0-lite", + name: "ByteDance Seed: Seed-2.0-Lite", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.25, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "bytedance-seed/seed-2.0-mini": { + id: "bytedance-seed/seed-2.0-mini", + name: "ByteDance Seed: Seed-2.0-Mini", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.09999999999999999, + output: 0.39999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "cohere/command-r-08-2024": { + id: "cohere/command-r-08-2024", + name: "Cohere: Command R (08-2024)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4000, + } satisfies Model<"openai-completions">, + "cohere/command-r-plus-08-2024": { + id: "cohere/command-r-plus-08-2024", + name: "Cohere: Command R+ (08-2024)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2.5, + output: 10, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4000, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-chat": { + id: "deepseek/deepseek-chat", + name: "DeepSeek: DeepSeek V3", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.32, + output: 0.8899999999999999, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-chat-v3-0324": { + id: "deepseek/deepseek-chat-v3-0324", + name: "DeepSeek: DeepSeek V3 0324", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.19999999999999998, + output: 0.77, + cacheRead: 0.135, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-chat-v3.1": { + id: "deepseek/deepseek-chat-v3.1", + name: "DeepSeek: DeepSeek V3.1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.21, + output: 0.7899999999999999, + cacheRead: 0.13, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-r1": { + id: "deepseek/deepseek-r1", + name: "DeepSeek: R1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.7, + output: 2.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 16000, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-r1-0528": { + id: "deepseek/deepseek-r1-0528", + name: "DeepSeek: R1 0528", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.5, + output: 2.1500000000000004, + cacheRead: 0.35, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-v3.1-terminus": { + id: "deepseek/deepseek-v3.1-terminus", + name: "DeepSeek: DeepSeek V3.1 Terminus", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.27, + output: 0.95, + cacheRead: 0.13, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-v3.2": { + id: "deepseek/deepseek-v3.2", + name: "DeepSeek: DeepSeek V3.2", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.252, + output: 0.378, + cacheRead: 0.0252, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-v3.2-exp": { + id: "deepseek/deepseek-v3.2-exp", + name: "DeepSeek: DeepSeek V3.2 Exp", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.27, + output: 0.41, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-v4-flash": { + id: "deepseek/deepseek-v4-flash", + name: "DeepSeek: DeepSeek V4 Flash", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, + input: ["text"], + cost: { + input: 0.09999999999999999, + output: 0.19999999999999998, + cacheRead: 0.02, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-v4-flash:free": { + id: "deepseek/deepseek-v4-flash:free", + name: "DeepSeek: DeepSeek V4 Flash (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 384000, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-v4-pro": { + id: "deepseek/deepseek-v4-pro", + name: "DeepSeek: DeepSeek V4 Pro", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, + input: ["text"], + cost: { + input: 0.435, + output: 0.87, + cacheRead: 0.003625, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 384000, + } satisfies Model<"openai-completions">, + "essentialai/rnj-1-instruct": { + id: "essentialai/rnj-1-instruct", + name: "EssentialAI: Rnj 1 Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.15, + output: 0.15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "google/gemini-2.0-flash-001": { + id: "google/gemini-2.0-flash-001", + name: "Google: Gemini 2.0 Flash", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.09999999999999999, + output: 0.39999999999999997, + cacheRead: 0.024999999999999998, + cacheWrite: 0.08333333333333334, + }, + contextWindow: 1000000, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "google/gemini-2.0-flash-lite-001": { + id: "google/gemini-2.0-flash-lite-001", + name: "Google: Gemini 2.0 Flash Lite", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.075, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "google/gemini-2.5-flash": { + id: "google/gemini-2.5-flash", + name: "Google: Gemini 2.5 Flash", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 2.5, + cacheRead: 0.03, + cacheWrite: 0.08333333333333334, + }, + contextWindow: 1048576, + maxTokens: 65535, + } satisfies Model<"openai-completions">, + "google/gemini-2.5-flash-lite": { + id: "google/gemini-2.5-flash-lite", + name: "Google: Gemini 2.5 Flash Lite", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.09999999999999999, + output: 0.39999999999999997, + cacheRead: 0.01, + cacheWrite: 0.08333333333333334, + }, + contextWindow: 1048576, + maxTokens: 65535, + } satisfies Model<"openai-completions">, + "google/gemini-2.5-flash-lite-preview-09-2025": { + id: "google/gemini-2.5-flash-lite-preview-09-2025", + name: "Google: Gemini 2.5 Flash Lite Preview 09-2025", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.09999999999999999, + output: 0.39999999999999997, + cacheRead: 0.01, + cacheWrite: 0.08333333333333334, + }, + contextWindow: 1048576, + maxTokens: 65535, + } satisfies Model<"openai-completions">, + "google/gemini-2.5-pro": { + id: "google/gemini-2.5-pro", + name: "Google: Gemini 2.5 Pro", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0.375, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "google/gemini-2.5-pro-preview": { + id: "google/gemini-2.5-pro-preview", + name: "Google: Gemini 2.5 Pro Preview 06-05", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0.375, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "google/gemini-2.5-pro-preview-05-06": { + id: "google/gemini-2.5-pro-preview-05-06", + name: "Google: Gemini 2.5 Pro Preview 05-06", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0.375, + }, + contextWindow: 1048576, + maxTokens: 65535, + } satisfies Model<"openai-completions">, + "google/gemini-3-flash-preview": { + id: "google/gemini-3-flash-preview", + name: "Google: Gemini 3 Flash Preview", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.5, + output: 3, + cacheRead: 0.049999999999999996, + cacheWrite: 0.08333333333333334, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "google/gemini-3.1-flash-lite": { + id: "google/gemini-3.1-flash-lite", + name: "Google: Gemini 3.1 Flash Lite", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.25, + output: 1.5, + cacheRead: 0.024999999999999998, + cacheWrite: 0.08333333333333334, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "google/gemini-3.1-flash-lite-preview": { + id: "google/gemini-3.1-flash-lite-preview", + name: "Google: Gemini 3.1 Flash Lite Preview", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.25, + output: 1.5, + cacheRead: 0.024999999999999998, + cacheWrite: 0.08333333333333334, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "google/gemini-3.1-pro-preview": { + id: "google/gemini-3.1-pro-preview", + name: "Google: Gemini 3.1 Pro Preview", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 12, + cacheRead: 0.19999999999999998, + cacheWrite: 0.375, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "google/gemini-3.1-pro-preview-customtools": { + id: "google/gemini-3.1-pro-preview-customtools", + name: "Google: Gemini 3.1 Pro Preview Custom Tools", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 12, + cacheRead: 0.19999999999999998, + cacheWrite: 0.375, + }, + contextWindow: 1048756, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "google/gemini-3.5-flash": { + id: "google/gemini-3.5-flash", + name: "Google: Gemini 3.5 Flash", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.5, + output: 9, + cacheRead: 0.15, + cacheWrite: 0.08333333333333334, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "google/gemma-3-12b-it": { + id: "google/gemma-3-12b-it", + name: "Google: Gemma 3 12B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.04, + output: 0.13, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "google/gemma-3-27b-it": { + id: "google/gemma-3-27b-it", + name: "Google: Gemma 3 27B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.08, + output: 0.16, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "google/gemma-4-26b-a4b-it": { + id: "google/gemma-4-26b-a4b-it", + name: "Google: Gemma 4 26B A4B ", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.06, + output: 0.33, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "google/gemma-4-26b-a4b-it:free": { + id: "google/gemma-4-26b-a4b-it:free", + name: "Google: Gemma 4 26B A4B (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "google/gemma-4-31b-it": { + id: "google/gemma-4-31b-it", + name: "Google: Gemma 4 31B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.12, + output: 0.37, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "google/gemma-4-31b-it:free": { + id: "google/gemma-4-31b-it:free", + name: "Google: Gemma 4 31B (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "ibm-granite/granite-4.1-8b": { + id: "ibm-granite/granite-4.1-8b", + name: "IBM: Granite 4.1 8B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.049999999999999996, + output: 0.09999999999999999, + cacheRead: 0.049999999999999996, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "inception/mercury-2": { + id: "inception/mercury-2", + name: "Inception: Mercury 2", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text"], + cost: { + input: 0.25, + output: 0.75, + cacheRead: 0.024999999999999998, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 50000, + } satisfies Model<"openai-completions">, + "inclusionai/ling-2.6-1t": { + id: "inclusionai/ling-2.6-1t", + name: "inclusionAI: Ling-2.6-1T", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.075, + output: 0.625, + cacheRead: 0.015, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "inclusionai/ling-2.6-flash": { + id: "inclusionai/ling-2.6-flash", + name: "inclusionAI: Ling-2.6-flash", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.01, + output: 0.03, + cacheRead: 0.002, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "inclusionai/ring-2.6-1t": { + id: "inclusionai/ring-2.6-1t", + name: "inclusionAI: Ring-2.6-1T", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.075, + output: 0.625, + cacheRead: 0.015, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "kwaipilot/kat-coder-pro-v2": { + id: "kwaipilot/kat-coder-pro-v2", + name: "Kwaipilot: KAT-Coder-Pro V2", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 80000, + } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-70b-instruct": { + id: "meta-llama/llama-3.1-70b-instruct", + name: "Meta: Llama 3.1 70B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.39999999999999997, + output: 0.39999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-8b-instruct": { + id: "meta-llama/llama-3.1-8b-instruct", + name: "Meta: Llama 3.1 8B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.02, + output: 0.049999999999999996, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "meta-llama/llama-3.3-70b-instruct": { + id: "meta-llama/llama-3.3-70b-instruct", + name: "Meta: Llama 3.3 70B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.09999999999999999, + output: 0.32, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "meta-llama/llama-3.3-70b-instruct:free": { + id: "meta-llama/llama-3.3-70b-instruct:free", + name: "Meta: Llama 3.3 70B Instruct (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "meta-llama/llama-4-scout": { + id: "meta-llama/llama-4-scout", + name: "Meta: Llama 4 Scout", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.08, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 10000000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "minimax/minimax-m1": { + id: "minimax/minimax-m1", + name: "MiniMax: MiniMax M1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.39999999999999997, + output: 2.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 40000, + } satisfies Model<"openai-completions">, + "minimax/minimax-m2": { + id: "minimax/minimax-m2", + name: "MiniMax: MiniMax M2", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.255, + output: 1, + cacheRead: 0.03, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 196608, + } satisfies Model<"openai-completions">, + "minimax/minimax-m2.1": { + id: "minimax/minimax-m2.1", + name: "MiniMax: MiniMax M2.1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.29, + output: 0.95, + cacheRead: 0.03, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 196608, + } satisfies Model<"openai-completions">, + "minimax/minimax-m2.5": { + id: "minimax/minimax-m2.5", + name: "MiniMax: MiniMax M2.5", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.15, + output: 1.15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 196608, + } satisfies Model<"openai-completions">, + "minimax/minimax-m2.5:free": { + id: "minimax/minimax-m2.5:free", + name: "MiniMax: MiniMax M2.5 (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "minimax/minimax-m2.7": { + id: "minimax/minimax-m2.7", + name: "MiniMax: MiniMax M2.7", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.27899999999999997, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "mistralai/codestral-2508": { + id: "mistralai/codestral-2508", + name: "Mistral: Codestral 2508", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.3, + output: 0.8999999999999999, + cacheRead: 0.03, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/devstral-2512": { + id: "mistralai/devstral-2512", + name: "Mistral: Devstral 2 2512", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.39999999999999997, + output: 2, + cacheRead: 0.04, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/devstral-medium": { + id: "mistralai/devstral-medium", + name: "Mistral: Devstral Medium", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.39999999999999997, + output: 2, + cacheRead: 0.04, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/devstral-small": { + id: "mistralai/devstral-small", + name: "Mistral: Devstral Small 1.1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.09999999999999999, + output: 0.3, + cacheRead: 0.01, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/ministral-14b-2512": { + id: "mistralai/ministral-14b-2512", + name: "Mistral: Ministral 3 14B 2512", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.19999999999999998, + output: 0.19999999999999998, + cacheRead: 0.02, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/ministral-3b-2512": { + id: "mistralai/ministral-3b-2512", + name: "Mistral: Ministral 3 3B 2512", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.09999999999999999, + output: 0.09999999999999999, + cacheRead: 0.01, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/ministral-8b-2512": { + id: "mistralai/ministral-8b-2512", + name: "Mistral: Ministral 3 8B 2512", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.15, + cacheRead: 0.015, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/mistral-large": { + id: "mistralai/mistral-large", + name: "Mistral Large", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2, + output: 6, + cacheRead: 0.19999999999999998, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/mistral-large-2407": { + id: "mistralai/mistral-large-2407", + name: "Mistral Large 2407", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2, + output: 6, + cacheRead: 0.19999999999999998, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/mistral-large-2411": { + id: "mistralai/mistral-large-2411", + name: "Mistral Large 2411", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2, + output: 6, + cacheRead: 0.19999999999999998, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/mistral-large-2512": { + id: "mistralai/mistral-large-2512", + name: "Mistral: Mistral Large 3 2512", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.5, + output: 1.5, + cacheRead: 0.049999999999999996, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/mistral-medium-3": { + id: "mistralai/mistral-medium-3", + name: "Mistral: Mistral Medium 3", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.39999999999999997, + output: 2, + cacheRead: 0.04, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/mistral-medium-3-5": { + id: "mistralai/mistral-medium-3-5", + name: "Mistral: Mistral Medium 3.5", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.5, + output: 7.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/mistral-medium-3.1": { + id: "mistralai/mistral-medium-3.1", + name: "Mistral: Mistral Medium 3.1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.39999999999999997, + output: 2, + cacheRead: 0.04, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/mistral-nemo": { + id: "mistralai/mistral-nemo", + name: "Mistral: Mistral Nemo", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.02, + output: 0.03, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/mistral-saba": { + id: "mistralai/mistral-saba", + name: "Mistral: Saba", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.19999999999999998, + output: 0.6, + cacheRead: 0.02, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/mistral-small-2603": { + id: "mistralai/mistral-small-2603", + name: "Mistral: Mistral Small 4", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0.015, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/mistral-small-3.2-24b-instruct": { + id: "mistralai/mistral-small-3.2-24b-instruct", + name: "Mistral: Mistral Small 3.2 24B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.075, + output: 0.19999999999999998, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "mistralai/mixtral-8x22b-instruct": { + id: "mistralai/mixtral-8x22b-instruct", + name: "Mistral: Mixtral 8x22B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2, + output: 6, + cacheRead: 0.19999999999999998, + cacheWrite: 0, + }, + contextWindow: 65536, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/pixtral-large-2411": { + id: "mistralai/pixtral-large-2411", + name: "Mistral: Pixtral Large 2411", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2, + output: 6, + cacheRead: 0.19999999999999998, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/voxtral-small-24b-2507": { + id: "mistralai/voxtral-small-24b-2507", + name: "Mistral: Voxtral Small 24B 2507", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.09999999999999999, + output: 0.3, + cacheRead: 0.01, + cacheWrite: 0, + }, + contextWindow: 32000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "moonshotai/kimi-k2": { + id: "moonshotai/kimi-k2", + name: "MoonshotAI: Kimi K2 0711", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.5700000000000001, + output: 2.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "moonshotai/kimi-k2-0905": { + id: "moonshotai/kimi-k2-0905", + name: "MoonshotAI: Kimi K2 0905", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.6, + output: 2.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "moonshotai/kimi-k2-thinking": { + id: "moonshotai/kimi-k2-thinking", + name: "MoonshotAI: Kimi K2 Thinking", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.6, + output: 2.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "moonshotai/kimi-k2.5": { + id: "moonshotai/kimi-k2.5", + name: "MoonshotAI: Kimi K2.5", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.41, + output: 2.06, + cacheRead: 0.07, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "moonshotai/kimi-k2.6": { + id: "moonshotai/kimi-k2.6", + name: "MoonshotAI: Kimi K2.6", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.73, + output: 3.49, + cacheRead: 0.25, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262142, + } satisfies Model<"openai-completions">, + "nex-agi/deepseek-v3.1-nex-n1": { + id: "nex-agi/deepseek-v3.1-nex-n1", + name: "Nex AGI: DeepSeek V3.1 Nex N1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.135, + output: 0.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 163840, + } satisfies Model<"openai-completions">, + "nvidia/llama-3.3-nemotron-super-49b-v1.5": { + id: "nvidia/llama-3.3-nemotron-super-49b-v1.5", + name: "NVIDIA: Llama 3.3 Nemotron Super 49B V1.5", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.09999999999999999, + output: 0.39999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "nvidia/nemotron-3-nano-30b-a3b": { + id: "nvidia/nemotron-3-nano-30b-a3b", + name: "NVIDIA: Nemotron 3 Nano 30B A3B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.049999999999999996, + output: 0.19999999999999998, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 228000, + } satisfies Model<"openai-completions">, + "nvidia/nemotron-3-nano-30b-a3b:free": { + id: "nvidia/nemotron-3-nano-30b-a3b:free", + name: "NVIDIA: Nemotron 3 Nano 30B A3B (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free": { + id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free", + name: "NVIDIA: Nemotron 3 Nano Omni (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "nvidia/nemotron-3-super-120b-a12b": { + id: "nvidia/nemotron-3-super-120b-a12b", + name: "NVIDIA: Nemotron 3 Super", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.09, + output: 0.44999999999999996, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "nvidia/nemotron-3-super-120b-a12b:free": { + id: "nvidia/nemotron-3-super-120b-a12b:free", + name: "NVIDIA: Nemotron 3 Super (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "nvidia/nemotron-nano-12b-v2-vl:free": { + id: "nvidia/nemotron-nano-12b-v2-vl:free", + name: "NVIDIA: Nemotron Nano 12B 2 VL (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "nvidia/nemotron-nano-9b-v2": { + id: "nvidia/nemotron-nano-9b-v2", + name: "NVIDIA: Nemotron Nano 9B V2", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.04, + output: 0.16, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "nvidia/nemotron-nano-9b-v2:free": { + id: "nvidia/nemotron-nano-9b-v2:free", + name: "NVIDIA: Nemotron Nano 9B V2 (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-3.5-turbo": { + id: "openai/gpt-3.5-turbo", + name: "OpenAI: GPT-3.5 Turbo", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.5, + output: 1.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 16385, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-3.5-turbo-0613": { + id: "openai/gpt-3.5-turbo-0613", + name: "OpenAI: GPT-3.5 Turbo (older v0613)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 4095, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-3.5-turbo-16k": { + id: "openai/gpt-3.5-turbo-16k", + name: "OpenAI: GPT-3.5 Turbo 16k", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 3, + output: 4, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 16385, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-4": { + id: "openai/gpt-4", + name: "OpenAI: GPT-4", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 30, + output: 60, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8191, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-4-0314": { + id: "openai/gpt-4-0314", + name: "OpenAI: GPT-4 (older v0314)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 30, + output: 60, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8191, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-4-1106-preview": { + id: "openai/gpt-4-1106-preview", + name: "OpenAI: GPT-4 Turbo (older v1106)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 10, + output: 30, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-4-turbo": { + id: "openai/gpt-4-turbo", + name: "OpenAI: GPT-4 Turbo", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 10, + output: 30, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-4-turbo-preview": { + id: "openai/gpt-4-turbo-preview", + name: "OpenAI: GPT-4 Turbo Preview", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 10, + output: 30, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-4.1": { + id: "openai/gpt-4.1", + name: "OpenAI: GPT-4.1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2, + output: 8, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 1047576, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-4.1-mini": { + id: "openai/gpt-4.1-mini", + name: "OpenAI: GPT-4.1 Mini", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.39999999999999997, + output: 1.5999999999999999, + cacheRead: 0.09999999999999999, + cacheWrite: 0, + }, + contextWindow: 1047576, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "openai/gpt-4.1-nano": { + id: "openai/gpt-4.1-nano", + name: "OpenAI: GPT-4.1 Nano", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.09999999999999999, + output: 0.39999999999999997, + cacheRead: 0.024999999999999998, + cacheWrite: 0, + }, + contextWindow: 1047576, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "openai/gpt-4o": { + id: "openai/gpt-4o", + name: "OpenAI: GPT-4o", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2.5, + output: 10, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "openai/gpt-4o-2024-05-13": { + id: "openai/gpt-4o-2024-05-13", + name: "OpenAI: GPT-4o (2024-05-13)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 5, + output: 15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-4o-2024-08-06": { + id: "openai/gpt-4o-2024-08-06", + name: "OpenAI: GPT-4o (2024-08-06)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2.5, + output: 10, + cacheRead: 1.25, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "openai/gpt-4o-2024-11-20": { + id: "openai/gpt-4o-2024-11-20", + name: "OpenAI: GPT-4o (2024-11-20)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2.5, + output: 10, + cacheRead: 1.25, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "openai/gpt-4o-audio-preview": { + id: "openai/gpt-4o-audio-preview", + name: "OpenAI: GPT-4o Audio", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2.5, + output: 10, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "openai/gpt-4o-mini": { + id: "openai/gpt-4o-mini", + name: "OpenAI: GPT-4o-mini", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "openai/gpt-4o-mini-2024-07-18": { + id: "openai/gpt-4o-mini-2024-07-18", + name: "OpenAI: GPT-4o-mini (2024-07-18)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "openai/gpt-5": { + id: "openai/gpt-5", + name: "OpenAI: GPT-5", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-5-codex": { + id: "openai/gpt-5-codex", + name: "OpenAI: GPT-5 Codex", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-5-mini": { + id: "openai/gpt-5-mini", + name: "OpenAI: GPT-5 Mini", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.25, + output: 2, + cacheRead: 0.024999999999999998, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-5-nano": { + id: "openai/gpt-5-nano", + name: "OpenAI: GPT-5 Nano", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.049999999999999996, + output: 0.39999999999999997, + cacheRead: 0.01, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-5-pro": { + id: "openai/gpt-5-pro", + name: "OpenAI: GPT-5 Pro", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 120, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-5.1": { + id: "openai/gpt-5.1", + name: "OpenAI: GPT-5.1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.13, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-5.1-chat": { + id: "openai/gpt-5.1-chat", + name: "OpenAI: GPT-5.1 Chat", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "openai/gpt-5.1-codex": { + id: "openai/gpt-5.1-codex", + name: "OpenAI: GPT-5.1-Codex", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-5.1-codex-max": { + id: "openai/gpt-5.1-codex-max", + name: "OpenAI: GPT-5.1-Codex-Max", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-5.1-codex-mini": { + id: "openai/gpt-5.1-codex-mini", + name: "OpenAI: GPT-5.1-Codex-Mini", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.25, + output: 2, + cacheRead: 0.03, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-5.2": { + id: "openai/gpt-5.2", + name: "OpenAI: GPT-5.2", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-5.2-chat": { + id: "openai/gpt-5.2-chat", + name: "OpenAI: GPT-5.2 Chat", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 32000, + } satisfies Model<"openai-completions">, + "openai/gpt-5.2-codex": { + id: "openai/gpt-5.2-codex", + name: "OpenAI: GPT-5.2-Codex", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-5.2-pro": { + id: "openai/gpt-5.2-pro", + name: "OpenAI: GPT-5.2 Pro", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 21, + output: 168, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-5.3-chat": { + id: "openai/gpt-5.3-chat", + name: "OpenAI: GPT-5.3 Chat", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "openai/gpt-5.3-codex": { + id: "openai/gpt-5.3-codex", + name: "OpenAI: GPT-5.3-Codex", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-5.4": { + id: "openai/gpt-5.4", + name: "OpenAI: GPT-5.4", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 2.5, + output: 15, + cacheRead: 0.25, + cacheWrite: 0, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-5.4-mini": { + id: "openai/gpt-5.4-mini", + name: "OpenAI: GPT-5.4 Mini", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 0.75, + output: 4.5, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-5.4-nano": { + id: "openai/gpt-5.4-nano", + name: "OpenAI: GPT-5.4 Nano", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 0.19999999999999998, + output: 1.25, + cacheRead: 0.02, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-5.4-pro": { + id: "openai/gpt-5.4-pro", + name: "OpenAI: GPT-5.4 Pro", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 30, + output: 180, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-5.5": { + id: "openai/gpt-5.5", + name: "OpenAI: GPT-5.5", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 30, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-5.5-pro": { + id: "openai/gpt-5.5-pro", + name: "OpenAI: GPT-5.5 Pro", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 30, + output: 180, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-audio": { + id: "openai/gpt-audio", + name: "OpenAI: GPT Audio", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2.5, + output: 10, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "openai/gpt-audio-mini": { + id: "openai/gpt-audio-mini", + name: "OpenAI: GPT Audio Mini", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.6, + output: 2.4, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "openai/gpt-chat-latest": { + id: "openai/gpt-chat-latest", + name: "OpenAI: GPT Chat Latest", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 5, + output: 30, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-oss-120b": { + id: "openai/gpt-oss-120b", + name: "OpenAI: gpt-oss-120b", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.039, + output: 0.18, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-oss-120b:free": { + id: "openai/gpt-oss-120b:free", + name: "OpenAI: gpt-oss-120b (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "openai/gpt-oss-20b": { + id: "openai/gpt-oss-20b", + name: "OpenAI: gpt-oss-20b", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.03, + output: 0.14, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "openai/gpt-oss-20b:free": { + id: "openai/gpt-oss-20b:free", + name: "OpenAI: gpt-oss-20b (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "openai/gpt-oss-safeguard-20b": { + id: "openai/gpt-oss-safeguard-20b", + name: "OpenAI: gpt-oss-safeguard-20b", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.075, + output: 0.3, + cacheRead: 0.037, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "openai/o1": { + id: "openai/o1", + name: "OpenAI: o1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 60, + cacheRead: 7.5, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-completions">, + "openai/o3": { + id: "openai/o3", + name: "OpenAI: o3", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 8, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-completions">, + "openai/o3-deep-research": { + id: "openai/o3-deep-research", + name: "OpenAI: o3 Deep Research", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 10, + output: 40, + cacheRead: 2.5, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-completions">, + "openai/o3-mini": { + id: "openai/o3-mini", + name: "OpenAI: o3 Mini", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 1.1, + output: 4.4, + cacheRead: 0.55, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-completions">, + "openai/o3-mini-high": { + id: "openai/o3-mini-high", + name: "OpenAI: o3 Mini High", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 1.1, + output: 4.4, + cacheRead: 0.55, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-completions">, + "openai/o3-pro": { + id: "openai/o3-pro", + name: "OpenAI: o3 Pro", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 20, + output: 80, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-completions">, + "openai/o4-mini": { + id: "openai/o4-mini", + name: "OpenAI: o4 Mini", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.1, + output: 4.4, + cacheRead: 0.275, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-completions">, + "openai/o4-mini-deep-research": { + id: "openai/o4-mini-deep-research", + name: "OpenAI: o4 Mini Deep Research", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 8, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-completions">, + "openai/o4-mini-high": { + id: "openai/o4-mini-high", + name: "OpenAI: o4 Mini High", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.1, + output: 4.4, + cacheRead: 0.275, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-completions">, + "openrouter/auto": { + id: "openrouter/auto", + name: "Auto Router", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: -1000000, + output: -1000000, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 2000000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openrouter/free": { + id: "openrouter/free", + name: "Free Models Router", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openrouter/owl-alpha": { + id: "openrouter/owl-alpha", + name: "Owl Alpha", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048756, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "poolside/laguna-m.1:free": { + id: "poolside/laguna-m.1:free", + name: "Poolside: Laguna M.1 (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "poolside/laguna-xs.2:free": { + id: "poolside/laguna-xs.2:free", + name: "Poolside: Laguna XS.2 (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "prime-intellect/intellect-3": { + id: "prime-intellect/intellect-3", + name: "Prime Intellect: INTELLECT-3", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.19999999999999998, + output: 1.1, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "qwen/qwen-2.5-72b-instruct": { + id: "qwen/qwen-2.5-72b-instruct", + name: "Qwen2.5 72B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.36, + output: 0.39999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "qwen/qwen-2.5-7b-instruct": { + id: "qwen/qwen-2.5-7b-instruct", + name: "Qwen: Qwen2.5 7B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.04, + output: 0.09999999999999999, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen/qwen-plus": { + id: "qwen/qwen-plus", + name: "Qwen: Qwen-Plus", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.26, + output: 0.78, + cacheRead: 0.052000000000000005, + cacheWrite: 0.325, + }, + contextWindow: 1000000, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen/qwen-plus-2025-07-28": { + id: "qwen/qwen-plus-2025-07-28", + name: "Qwen: Qwen Plus 0728", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.26, + output: 0.78, + cacheRead: 0, + cacheWrite: 0.325, + }, + contextWindow: 1000000, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen/qwen-plus-2025-07-28:thinking": { + id: "qwen/qwen-plus-2025-07-28:thinking", + name: "Qwen: Qwen Plus 0728 (thinking)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.26, + output: 0.78, + cacheRead: 0, + cacheWrite: 0.325, + }, + contextWindow: 1000000, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen/qwen3-14b": { + id: "qwen/qwen3-14b", + name: "Qwen: Qwen3 14B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.09999999999999999, + output: 0.24, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131702, + maxTokens: 40960, + } satisfies Model<"openai-completions">, + "qwen/qwen3-235b-a22b": { + id: "qwen/qwen3-235b-a22b", + name: "Qwen: Qwen3 235B A22B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.45499999999999996, + output: 1.8199999999999998, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "qwen/qwen3-235b-a22b-2507": { + id: "qwen/qwen3-235b-a22b-2507", + name: "Qwen: Qwen3 235B A22B Instruct 2507", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.071, + output: 0.09999999999999999, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "qwen/qwen3-235b-a22b-thinking-2507": { + id: "qwen/qwen3-235b-a22b-thinking-2507", + name: "Qwen: Qwen3 235B A22B Thinking 2507", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.14950000000000002, + output: 1.495, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "qwen/qwen3-30b-a3b": { + id: "qwen/qwen3-30b-a3b", + name: "Qwen: Qwen3 30B A3B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.09, + output: 0.44999999999999996, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 20000, + } satisfies Model<"openai-completions">, + "qwen/qwen3-30b-a3b-instruct-2507": { + id: "qwen/qwen3-30b-a3b-instruct-2507", + name: "Qwen: Qwen3 30B A3B Instruct 2507", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.09, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "qwen/qwen3-30b-a3b-thinking-2507": { + id: "qwen/qwen3-30b-a3b-thinking-2507", + name: "Qwen: Qwen3 30B A3B Thinking 2507", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.08, + output: 0.39999999999999997, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "qwen/qwen3-32b": { + id: "qwen/qwen3-32b", + name: "Qwen: Qwen3 32B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.08, + output: 0.28, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "qwen/qwen3-8b": { + id: "qwen/qwen3-8b", + name: "Qwen: Qwen3 8B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.049999999999999996, + output: 0.39999999999999997, + cacheRead: 0.049999999999999996, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "qwen/qwen3-coder": { + id: "qwen/qwen3-coder", + name: "Qwen: Qwen3 Coder 480B A35B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.22, + output: 1.7999999999999998, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "qwen/qwen3-coder-30b-a3b-instruct": { + id: "qwen/qwen3-coder-30b-a3b-instruct", + name: "Qwen: Qwen3 Coder 30B A3B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.07, + output: 0.27, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 160000, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen/qwen3-coder-flash": { + id: "qwen/qwen3-coder-flash", + name: "Qwen: Qwen3 Coder Flash", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.195, + output: 0.975, + cacheRead: 0.039, + cacheWrite: 0.24375, + }, + contextWindow: 1000000, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "qwen/qwen3-coder-next": { + id: "qwen/qwen3-coder-next", + name: "Qwen: Qwen3 Coder Next", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.11, + output: 0.7999999999999999, + cacheRead: 0.07, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "qwen/qwen3-coder-plus": { + id: "qwen/qwen3-coder-plus", + name: "Qwen: Qwen3 Coder Plus", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.65, + output: 3.25, + cacheRead: 0.13, + cacheWrite: 0.8125, + }, + contextWindow: 1000000, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "qwen/qwen3-coder:free": { + id: "qwen/qwen3-coder:free", + name: "Qwen: Qwen3 Coder 480B A35B (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 262000, + } satisfies Model<"openai-completions">, + "qwen/qwen3-max": { + id: "qwen/qwen3-max", + name: "Qwen: Qwen3 Max", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.78, + output: 3.9, + cacheRead: 0.156, + cacheWrite: 0.975, + }, + contextWindow: 262144, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen/qwen3-max-thinking": { + id: "qwen/qwen3-max-thinking", + name: "Qwen: Qwen3 Max Thinking", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.78, + output: 3.9, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen/qwen3-next-80b-a3b-instruct": { + id: "qwen/qwen3-next-80b-a3b-instruct", + name: "Qwen: Qwen3 Next 80B A3B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.09, + output: 1.1, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "qwen/qwen3-next-80b-a3b-instruct:free": { + id: "qwen/qwen3-next-80b-a3b-instruct:free", + name: "Qwen: Qwen3 Next 80B A3B Instruct (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "qwen/qwen3-next-80b-a3b-thinking": { + id: "qwen/qwen3-next-80b-a3b-thinking", + name: "Qwen: Qwen3 Next 80B A3B Thinking", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.0975, + output: 0.78, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen/qwen3-vl-235b-a22b-instruct": { + id: "qwen/qwen3-vl-235b-a22b-instruct", + name: "Qwen: Qwen3 VL 235B A22B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.19999999999999998, + output: 0.88, + cacheRead: 0.11, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "qwen/qwen3-vl-235b-a22b-thinking": { + id: "qwen/qwen3-vl-235b-a22b-thinking", + name: "Qwen: Qwen3 VL 235B A22B Thinking", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.26, + output: 2.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen/qwen3-vl-30b-a3b-instruct": { + id: "qwen/qwen3-vl-30b-a3b-instruct", + name: "Qwen: Qwen3 VL 30B A3B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.13, + output: 0.52, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen/qwen3-vl-30b-a3b-thinking": { + id: "qwen/qwen3-vl-30b-a3b-thinking", + name: "Qwen: Qwen3 VL 30B A3B Thinking", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.13, + output: 1.56, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen/qwen3-vl-32b-instruct": { + id: "qwen/qwen3-vl-32b-instruct", + name: "Qwen: Qwen3 VL 32B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.10400000000000001, + output: 0.41600000000000004, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen/qwen3-vl-8b-instruct": { + id: "qwen/qwen3-vl-8b-instruct", + name: "Qwen: Qwen3 VL 8B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.08, + output: 0.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen/qwen3-vl-8b-thinking": { + id: "qwen/qwen3-vl-8b-thinking", + name: "Qwen: Qwen3 VL 8B Thinking", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.117, + output: 1.365, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen/qwen3.5-122b-a10b": { + id: "qwen/qwen3.5-122b-a10b", + name: "Qwen: Qwen3.5-122B-A10B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.26, + output: 2.08, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "qwen/qwen3.5-27b": { + id: "qwen/qwen3.5-27b", + name: "Qwen: Qwen3.5-27B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.195, + output: 1.56, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "qwen/qwen3.5-35b-a3b": { + id: "qwen/qwen3.5-35b-a3b", + name: "Qwen: Qwen3.5-35B-A3B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.13899999999999998, + output: 1, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "qwen/qwen3.5-397b-a17b": { + id: "qwen/qwen3.5-397b-a17b", + name: "Qwen: Qwen3.5 397B A17B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.39, + output: 2.34, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "qwen/qwen3.5-9b": { + id: "qwen/qwen3.5-9b", + name: "Qwen: Qwen3.5-9B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.04, + output: 0.15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 81920, + } satisfies Model<"openai-completions">, + "qwen/qwen3.5-flash-02-23": { + id: "qwen/qwen3.5-flash-02-23", + name: "Qwen: Qwen3.5-Flash", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.065, + output: 0.26, + cacheRead: 0, + cacheWrite: 0.08125, + }, + contextWindow: 1000000, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "qwen/qwen3.5-plus-02-15": { + id: "qwen/qwen3.5-plus-02-15", + name: "Qwen: Qwen3.5 Plus 2026-02-15", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.26, + output: 1.56, + cacheRead: 0, + cacheWrite: 0.325, + }, + contextWindow: 1000000, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "qwen/qwen3.5-plus-20260420": { + id: "qwen/qwen3.5-plus-20260420", + name: "Qwen: Qwen3.5 Plus 2026-04-20", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 1.7999999999999998, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "qwen/qwen3.6-27b": { + id: "qwen/qwen3.6-27b", + name: "Qwen: Qwen3.6 27B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 3.1999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "qwen/qwen3.6-35b-a3b": { + id: "qwen/qwen3.6-35b-a3b", + name: "Qwen: Qwen3.6 35B A3B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.15, + output: 1, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262140, + } satisfies Model<"openai-completions">, + "qwen/qwen3.6-flash": { + id: "qwen/qwen3.6-flash", + name: "Qwen: Qwen3.6 Flash", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.1875, + output: 1.125, + cacheRead: 0, + cacheWrite: 0.234375, + }, + contextWindow: 1000000, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "qwen/qwen3.6-max-preview": { + id: "qwen/qwen3.6-max-preview", + name: "Qwen: Qwen3.6 Max Preview", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 1.04, + output: 6.24, + cacheRead: 0, + cacheWrite: 1.3, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "qwen/qwen3.6-plus": { + id: "qwen/qwen3.6-plus", + name: "Qwen: Qwen3.6 Plus", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.325, + output: 1.95, + cacheRead: 0, + cacheWrite: 0.40625, + }, + contextWindow: 1000000, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "qwen/qwen3.7-max": { + id: "qwen/qwen3.7-max", + name: "Qwen: Qwen3.7 Max", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 2.5, + output: 7.5, + cacheRead: 0, + cacheWrite: 3.125, + }, + contextWindow: 1000000, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "rekaai/reka-edge": { + id: "rekaai/reka-edge", + name: "Reka Edge", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.09999999999999999, + output: 0.09999999999999999, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 16384, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "relace/relace-search": { + id: "relace/relace-search", + name: "Relace: Relace Search", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1, + output: 3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "sao10k/l3-euryale-70b": { + id: "sao10k/l3-euryale-70b", + name: "Sao10k: Llama 3 Euryale 70B v2.1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1.48, + output: 1.48, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "sao10k/l3.1-euryale-70b": { + id: "sao10k/l3.1-euryale-70b", + name: "Sao10K: Llama 3.1 Euryale 70B v2.2", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.85, + output: 0.85, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "stepfun/step-3.5-flash": { + id: "stepfun/step-3.5-flash", + name: "StepFun: Step 3.5 Flash", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.09, + output: 0.3, + cacheRead: 0.02, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "tencent/hy3-preview": { + id: "tencent/hy3-preview", + name: "Tencent: Hy3 preview", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.06599999999999999, + output: 0.26, + cacheRead: 0.029, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "thedrummer/rocinante-12b": { + id: "thedrummer/rocinante-12b", + name: "TheDrummer: Rocinante 12B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.16999999999999998, + output: 0.43, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "thedrummer/unslopnemo-12b": { + id: "thedrummer/unslopnemo-12b", + name: "TheDrummer: UnslopNemo 12B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.39999999999999997, + output: 0.39999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "upstage/solar-pro-3": { + id: "upstage/solar-pro-3", + name: "Upstage: Solar Pro 3", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0.015, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "x-ai/grok-4.20": { + id: "x-ai/grok-4.20", + name: "xAI: Grok 4.20", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 2.5, + cacheRead: 0.19999999999999998, + cacheWrite: 0, + }, + contextWindow: 2000000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "x-ai/grok-4.3": { + id: "x-ai/grok-4.3", + name: "xAI: Grok 4.3", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 2.5, + cacheRead: 0.19999999999999998, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "x-ai/grok-build-0.1": { + id: "x-ai/grok-build-0.1", + name: "xAI: Grok Build 0.1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 2, + cacheRead: 0.19999999999999998, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "xiaomi/mimo-v2-flash": { + id: "xiaomi/mimo-v2-flash", + name: "Xiaomi: MiMo-V2-Flash", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.09999999999999999, + output: 0.3, + cacheRead: 0.01, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "xiaomi/mimo-v2-omni": { + id: "xiaomi/mimo-v2-omni", + name: "Xiaomi: MiMo-V2-Omni", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.39999999999999997, + output: 2, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "xiaomi/mimo-v2-pro": { + id: "xiaomi/mimo-v2-pro", + name: "Xiaomi: MiMo-V2-Pro", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 1, + output: 3, + cacheRead: 0.19999999999999998, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "xiaomi/mimo-v2.5": { + id: "xiaomi/mimo-v2.5", + name: "Xiaomi: MiMo-V2.5", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.39999999999999997, + output: 2, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "xiaomi/mimo-v2.5-pro": { + id: "xiaomi/mimo-v2.5-pro", + name: "Xiaomi: MiMo-V2.5-Pro", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 1, + output: 3, + cacheRead: 0.19999999999999998, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "z-ai/glm-4-32b": { + id: "z-ai/glm-4-32b", + name: "Z.ai: GLM 4 32B ", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.09999999999999999, + output: 0.09999999999999999, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "z-ai/glm-4.5": { + id: "z-ai/glm-4.5", + name: "Z.ai: GLM 4.5", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.6, + output: 2.2, + cacheRead: 0.11, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 98304, + } satisfies Model<"openai-completions">, + "z-ai/glm-4.5-air": { + id: "z-ai/glm-4.5-air", + name: "Z.ai: GLM 4.5 Air", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.13, + output: 0.85, + cacheRead: 0.024999999999999998, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 98304, + } satisfies Model<"openai-completions">, + "z-ai/glm-4.5-air:free": { + id: "z-ai/glm-4.5-air:free", + name: "Z.ai: GLM 4.5 Air (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 96000, + } satisfies Model<"openai-completions">, + "z-ai/glm-4.5v": { + id: "z-ai/glm-4.5v", + name: "Z.ai: GLM 4.5V", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.6, + output: 1.7999999999999998, + cacheRead: 0.11, + cacheWrite: 0, + }, + contextWindow: 65536, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "z-ai/glm-4.6": { + id: "z-ai/glm-4.6", + name: "Z.ai: GLM 4.6", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.43, + output: 1.74, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 202752, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "z-ai/glm-4.6v": { + id: "z-ai/glm-4.6v", + name: "Z.ai: GLM 4.6V", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 0.8999999999999999, + cacheRead: 0.049999999999999996, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 24000, + } satisfies Model<"openai-completions">, + "z-ai/glm-4.7": { + id: "z-ai/glm-4.7", + name: "Z.ai: GLM 4.7", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.39999999999999997, + output: 1.75, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 202752, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "z-ai/glm-4.7-flash": { + id: "z-ai/glm-4.7-flash", + name: "Z.ai: GLM 4.7 Flash", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.06, + output: 0.39999999999999997, + cacheRead: 0.01, + cacheWrite: 0, + }, + contextWindow: 202752, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "z-ai/glm-5": { + id: "z-ai/glm-5", + name: "Z.ai: GLM 5", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.6, + output: 1.9, + cacheRead: 0.119, + cacheWrite: 0, + }, + contextWindow: 202752, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "z-ai/glm-5-turbo": { + id: "z-ai/glm-5-turbo", + name: "Z.ai: GLM 5 Turbo", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 1.2, + output: 4, + cacheRead: 0.24, + cacheWrite: 0, + }, + contextWindow: 202752, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "z-ai/glm-5.1": { + id: "z-ai/glm-5.1", + name: "Z.ai: GLM 5.1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.98, + output: 3.08, + cacheRead: 0.182, + cacheWrite: 0, + }, + contextWindow: 202752, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "z-ai/glm-5v-turbo": { + id: "z-ai/glm-5v-turbo", + name: "Z.ai: GLM 5V Turbo", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.2, + output: 4, + cacheRead: 0.24, + cacheWrite: 0, + }, + contextWindow: 202752, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "~anthropic/claude-haiku-latest": { + id: "~anthropic/claude-haiku-latest", + name: "Anthropic Claude Haiku Latest", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 5, + cacheRead: 0.09999999999999999, + cacheWrite: 1.25, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "~anthropic/claude-opus-latest": { + id: "~anthropic/claude-opus-latest", + name: "Anthropic: Claude Opus Latest", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "~anthropic/claude-sonnet-latest": { + id: "~anthropic/claude-sonnet-latest", + name: "Anthropic Claude Sonnet Latest", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "~google/gemini-flash-latest": { + id: "~google/gemini-flash-latest", + name: "Google Gemini Flash Latest", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.5, + output: 9, + cacheRead: 0.15, + cacheWrite: 0.08333333333333334, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "~google/gemini-pro-latest": { + id: "~google/gemini-pro-latest", + name: "Google Gemini Pro Latest", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 12, + cacheRead: 0.19999999999999998, + cacheWrite: 0.375, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "~moonshotai/kimi-latest": { + id: "~moonshotai/kimi-latest", + name: "MoonshotAI Kimi Latest", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.73, + output: 3.49, + cacheRead: 0.25, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262142, + } satisfies Model<"openai-completions">, + "~openai/gpt-latest": { + id: "~openai/gpt-latest", + name: "OpenAI GPT Latest", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 5, + output: 30, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "~openai/gpt-mini-latest": { + id: "~openai/gpt-mini-latest", + name: "OpenAI GPT Mini Latest", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.75, + output: 4.5, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + }, + "together": { + "MiniMaxAI/MiniMax-M2.5": { + id: "MiniMaxAI/MiniMax-M2.5", + name: "MiniMax-M2.5", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":null,"medium":null}, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "MiniMaxAI/MiniMax-M2.7": { + id: "MiniMaxAI/MiniMax-M2.7", + name: "MiniMax-M2.7", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":null,"medium":null}, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0, + }, + contextWindow: 202752, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "Qwen/Qwen3-235B-A22B-Instruct-2507-tput": { + id: "Qwen/Qwen3-235B-A22B-Instruct-2507-tput", + name: "Qwen3 235B A22B Instruct 2507 FP8", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text"], + cost: { + input: 0.2, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": { + id: "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8", + name: "Qwen3 Coder 480B A35B Instruct", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: false, + input: ["text"], + cost: { + input: 2, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "Qwen/Qwen3-Coder-Next-FP8": { + id: "Qwen/Qwen3-Coder-Next-FP8", + name: "Qwen3 Coder Next FP8", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text"], + cost: { + input: 0.5, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "Qwen/Qwen3.5-397B-A17B": { + id: "Qwen/Qwen3.5-397B-A17B", + name: "Qwen3.5 397B A17B", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text", "image"], + cost: { + input: 0.6, + output: 3.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 130000, + } satisfies Model<"openai-completions">, + "Qwen/Qwen3.6-Plus": { + id: "Qwen/Qwen3.6-Plus", + name: "Qwen3.6 Plus", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text"], + cost: { + input: 0.5, + output: 3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 500000, + } satisfies Model<"openai-completions">, + "Qwen/Qwen3.7-Max": { + id: "Qwen/Qwen3.7-Max", + name: "Qwen3.7 Max", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text"], + cost: { + input: 2.5, + output: 7.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 500000, + } satisfies Model<"openai-completions">, + "deepseek-ai/DeepSeek-V3": { + id: "deepseek-ai/DeepSeek-V3", + name: "DeepSeek V3", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text"], + cost: { + input: 1.25, + output: 1.25, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "deepseek-ai/DeepSeek-V3-1": { + id: "deepseek-ai/DeepSeek-V3-1", + name: "DeepSeek V3.1", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text"], + cost: { + input: 0.6, + output: 1.7, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "deepseek-ai/DeepSeek-V4-Pro": { + id: "deepseek-ai/DeepSeek-V4-Pro", + name: "DeepSeek V4 Pro", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null}, + input: ["text"], + cost: { + input: 2.1, + output: 4.4, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 512000, + maxTokens: 384000, + } satisfies Model<"openai-completions">, + "essentialai/Rnj-1-Instruct": { + id: "essentialai/Rnj-1-Instruct", + name: "Rnj-1 Instruct", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0.15, + output: 0.15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "google/gemma-4-31B-it": { + id: "google/gemma-4-31B-it", + name: "Gemma 4 31B Instruct", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text", "image"], + cost: { + input: 0.2, + output: 0.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "meta-llama/Llama-3.3-70B-Instruct-Turbo": { + id: "meta-llama/Llama-3.3-70B-Instruct-Turbo", + name: "Llama 3.3 70B", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0.88, + output: 0.88, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "moonshotai/Kimi-K2.5": { + id: "moonshotai/Kimi-K2.5", + name: "Kimi K2.5", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text", "image"], + cost: { + input: 0.5, + output: 2.8, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "moonshotai/Kimi-K2.6": { + id: "moonshotai/Kimi-K2.6", + name: "Kimi K2.6", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text", "image"], + cost: { + input: 1.2, + output: 4.5, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 131000, + } satisfies Model<"openai-completions">, + "openai/gpt-oss-120b": { + id: "openai/gpt-oss-120b", + name: "GPT OSS 120B", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"openai"}, + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null}, + input: ["text"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "zai-org/GLM-5.1": { + id: "zai-org/GLM-5.1", + name: "GLM-5.1", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text"], + cost: { + input: 1.4, + output: 4.4, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 202752, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + }, + "vercel-ai-gateway": { + "alibaba/qwen-3-14b": { + id: "alibaba/qwen-3-14b", + name: "Qwen3-14B", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.12, + output: 0.24, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 40960, + maxTokens: 16384, + } satisfies Model<"anthropic-messages">, + "alibaba/qwen-3-235b": { + id: "alibaba/qwen-3-235b", + name: "Qwen3 235B A22b Instruct 2507", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text"], + cost: { + input: 0.6, + output: 1.2, + cacheRead: 0.6, + cacheWrite: 0, + }, + contextWindow: 131000, + maxTokens: 40000, + } satisfies Model<"anthropic-messages">, + "alibaba/qwen-3-30b": { + id: "alibaba/qwen-3-30b", + name: "Qwen3-30B-A3B", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.08, + output: 0.29, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 40960, + maxTokens: 16384, + } satisfies Model<"anthropic-messages">, + "alibaba/qwen-3-32b": { + id: "alibaba/qwen-3-32b", + name: "Qwen 3 32B", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.16, + output: 0.64, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 8192, + } satisfies Model<"anthropic-messages">, + "alibaba/qwen-3.6-max-preview": { + id: "alibaba/qwen-3.6-max-preview", + name: "Qwen 3.6 Max Preview", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.3, + output: 7.8, + cacheRead: 0.26, + cacheWrite: 1.625, + }, + contextWindow: 240000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "alibaba/qwen3-235b-a22b-thinking": { + id: "alibaba/qwen3-235b-a22b-thinking", + name: "Qwen3 VL 235B A22B Thinking", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.39999999999999997, + output: 4, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"anthropic-messages">, + "alibaba/qwen3-coder": { + id: "alibaba/qwen3-coder", + name: "Qwen3 Coder 480B A35B Instruct", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text"], + cost: { + input: 1.5, + output: 7.5, + cacheRead: 0.3, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"anthropic-messages">, + "alibaba/qwen3-coder-30b-a3b": { + id: "alibaba/qwen3-coder-30b-a3b", + name: "Qwen 3 Coder 30B A3B Instruct", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 8192, + } satisfies Model<"anthropic-messages">, + "alibaba/qwen3-coder-next": { + id: "alibaba/qwen3-coder-next", + name: "Qwen3 Coder Next", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text"], + cost: { + input: 0.5, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"anthropic-messages">, + "alibaba/qwen3-coder-plus": { + id: "alibaba/qwen3-coder-plus", + name: "Qwen3 Coder Plus", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text"], + cost: { + input: 1, + output: 5, + cacheRead: 0.19999999999999998, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 65536, + } satisfies Model<"anthropic-messages">, + "alibaba/qwen3-max": { + id: "alibaba/qwen3-max", + name: "Qwen3 Max", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text"], + cost: { + input: 1.2, + output: 6, + cacheRead: 0.24, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32768, + } satisfies Model<"anthropic-messages">, + "alibaba/qwen3-max-preview": { + id: "alibaba/qwen3-max-preview", + name: "Qwen3 Max Preview", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text"], + cost: { + input: 1.2, + output: 6, + cacheRead: 0.24, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32768, + } satisfies Model<"anthropic-messages">, + "alibaba/qwen3-max-thinking": { + id: "alibaba/qwen3-max-thinking", + name: "Qwen 3 Max Thinking", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 1.2, + output: 6, + cacheRead: 0.24, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 65536, + } satisfies Model<"anthropic-messages">, + "alibaba/qwen3-vl-thinking": { + id: "alibaba/qwen3-vl-thinking", + name: "Qwen3 VL 235B A22B Thinking", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.39999999999999997, + output: 4, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"anthropic-messages">, + "alibaba/qwen3.5-flash": { + id: "alibaba/qwen3.5-flash", + name: "Qwen 3.5 Flash", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.09999999999999999, + output: 0.39999999999999997, + cacheRead: 0.001, + cacheWrite: 0.125, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "alibaba/qwen3.5-plus": { + id: "alibaba/qwen3.5-plus", + name: "Qwen 3.5 Plus", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.39999999999999997, + output: 2.4, + cacheRead: 0.04, + cacheWrite: 0.5, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "alibaba/qwen3.6-27b": { + id: "alibaba/qwen3.6-27b", + name: "Qwen 3.6 27B", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.6, + output: 3.5999999999999996, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"anthropic-messages">, + "alibaba/qwen3.6-plus": { + id: "alibaba/qwen3.6-plus", + name: "Qwen 3.6 Plus", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.5, + output: 3, + cacheRead: 0.09999999999999999, + cacheWrite: 0.625, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "alibaba/qwen3.7-max": { + id: "alibaba/qwen3.7-max", + name: "Qwen 3.7 Max", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 3.75, + cacheRead: 0.25, + cacheWrite: 1.5625, + }, + contextWindow: 991000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "anthropic/claude-3-haiku": { + id: "anthropic/claude-3-haiku", + name: "Claude 3 Haiku", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.25, + output: 1.25, + cacheRead: 0.03, + cacheWrite: 0.3, + }, + contextWindow: 200000, + maxTokens: 4096, + } satisfies Model<"anthropic-messages">, + "anthropic/claude-3.5-haiku": { + id: "anthropic/claude-3.5-haiku", + name: "Claude 3.5 Haiku", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.7999999999999999, + output: 4, + cacheRead: 0.08, + cacheWrite: 1, + }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"anthropic-messages">, + "anthropic/claude-haiku-4.5": { + id: "anthropic/claude-haiku-4.5", + name: "Claude Haiku 4.5", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 5, + cacheRead: 0.09999999999999999, + cacheWrite: 1.25, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "anthropic/claude-opus-4": { + id: "anthropic/claude-opus-4", + name: "Claude Opus 4", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + contextWindow: 200000, + maxTokens: 32000, + } satisfies Model<"anthropic-messages">, + "anthropic/claude-opus-4.1": { + id: "anthropic/claude-opus-4.1", + name: "Claude Opus 4.1", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + contextWindow: 200000, + maxTokens: 32000, + } satisfies Model<"anthropic-messages">, + "anthropic/claude-opus-4.5": { + id: "anthropic/claude-opus-4.5", + name: "Claude Opus 4.5", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "anthropic/claude-opus-4.6": { + id: "anthropic/claude-opus-4.6", + name: "Claude Opus 4.6", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"max"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "anthropic/claude-opus-4.7": { + id: "anthropic/claude-opus-4.7", + name: "Claude Opus 4.7", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "anthropic/claude-sonnet-4": { + id: "anthropic/claude-sonnet-4", + name: "Claude Sonnet 4", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "anthropic/claude-sonnet-4.5": { + id: "anthropic/claude-sonnet-4.5", + name: "Claude Sonnet 4.5", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "anthropic/claude-sonnet-4.6": { + id: "anthropic/claude-sonnet-4.6", + name: "Claude Sonnet 4.6", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "arcee-ai/trinity-large-preview": { + id: "arcee-ai/trinity-large-preview", + name: "Trinity Large Preview", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text"], + cost: { + input: 0.25, + output: 1, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131000, + maxTokens: 131000, + } satisfies Model<"anthropic-messages">, + "arcee-ai/trinity-large-thinking": { + id: "arcee-ai/trinity-large-thinking", + name: "Trinity Large Thinking", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.25, + output: 0.8999999999999999, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262100, + maxTokens: 80000, + } satisfies Model<"anthropic-messages">, + "bytedance/seed-1.6": { + id: "bytedance/seed-1.6", + name: "Seed 1.6", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.25, + output: 2, + cacheRead: 0.049999999999999996, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 32000, + } satisfies Model<"anthropic-messages">, + "cohere/command-a": { + id: "cohere/command-a", + name: "Command A", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text"], + cost: { + input: 2.5, + output: 10, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 8000, + } satisfies Model<"anthropic-messages">, + "deepseek/deepseek-r1": { + id: "deepseek/deepseek-r1", + name: "DeepSeek-R1", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 1.35, + output: 5.4, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 8192, + } satisfies Model<"anthropic-messages">, + "deepseek/deepseek-v3": { + id: "deepseek/deepseek-v3", + name: "DeepSeek V3 0324", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text"], + cost: { + input: 0.77, + output: 0.77, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 16384, + } satisfies Model<"anthropic-messages">, + "deepseek/deepseek-v3.1": { + id: "deepseek/deepseek-v3.1", + name: "DeepSeek-V3.1", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.56, + output: 1.68, + cacheRead: 0.28, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 8192, + } satisfies Model<"anthropic-messages">, + "deepseek/deepseek-v3.1-terminus": { + id: "deepseek/deepseek-v3.1-terminus", + name: "DeepSeek V3.1 Terminus", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.27, + output: 1, + cacheRead: 0.135, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 65536, + } satisfies Model<"anthropic-messages">, + "deepseek/deepseek-v3.2": { + id: "deepseek/deepseek-v3.2", + name: "DeepSeek V3.2", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text"], + cost: { + input: 0.28, + output: 0.42, + cacheRead: 0.028, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 8000, + } satisfies Model<"anthropic-messages">, + "deepseek/deepseek-v3.2-thinking": { + id: "deepseek/deepseek-v3.2-thinking", + name: "DeepSeek V3.2 Thinking", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text"], + cost: { + input: 0.62, + output: 1.85, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 8000, + } satisfies Model<"anthropic-messages">, + "deepseek/deepseek-v4-flash": { + id: "deepseek/deepseek-v4-flash", + name: "DeepSeek V4 Flash", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.14, + output: 0.28, + cacheRead: 0.0028, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 384000, + } satisfies Model<"anthropic-messages">, + "deepseek/deepseek-v4-pro": { + id: "deepseek/deepseek-v4-pro", + name: "DeepSeek V4 Pro", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.435, + output: 0.87, + cacheRead: 0.0036, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 384000, + } satisfies Model<"anthropic-messages">, + "google/gemini-2.0-flash": { + id: "google/gemini-2.0-flash", + name: "Gemini 2.0 Flash", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0.024999999999999998, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 8192, + } satisfies Model<"anthropic-messages">, + "google/gemini-2.0-flash-lite": { + id: "google/gemini-2.0-flash-lite", + name: "Gemini 2.0 Flash Lite", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.075, + output: 0.3, + cacheRead: 0.02, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 8192, + } satisfies Model<"anthropic-messages">, + "google/gemini-2.5-flash": { + id: "google/gemini-2.5-flash", + name: "Gemini 2.5 Flash", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 2.5, + cacheRead: 0.03, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 65536, + } satisfies Model<"anthropic-messages">, + "google/gemini-2.5-flash-lite": { + id: "google/gemini-2.5-flash-lite", + name: "Gemini 2.5 Flash Lite", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.09999999999999999, + output: 0.39999999999999997, + cacheRead: 0.01, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"anthropic-messages">, + "google/gemini-2.5-pro": { + id: "google/gemini-2.5-pro", + name: "Gemini 2.5 Pro", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"anthropic-messages">, + "google/gemini-3-flash": { + id: "google/gemini-3-flash", + name: "Gemini 3 Flash", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.5, + output: 3, + cacheRead: 0.049999999999999996, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 65000, + } satisfies Model<"anthropic-messages">, + "google/gemini-3-pro-preview": { + id: "google/gemini-3-pro-preview", + name: "Gemini 3 Pro Preview", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 12, + cacheRead: 0.19999999999999998, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "google/gemini-3.1-flash-lite": { + id: "google/gemini-3.1-flash-lite", + name: "Gemini 3.1 Flash Lite", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.25, + output: 1.5, + cacheRead: 0.03, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 65000, + } satisfies Model<"anthropic-messages">, + "google/gemini-3.1-flash-lite-preview": { + id: "google/gemini-3.1-flash-lite-preview", + name: "Gemini 3.1 Flash Lite Preview", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.25, + output: 1.5, + cacheRead: 0.03, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 65000, + } satisfies Model<"anthropic-messages">, + "google/gemini-3.1-pro-preview": { + id: "google/gemini-3.1-pro-preview", + name: "Gemini 3.1 Pro Preview", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 12, + cacheRead: 0.19999999999999998, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "google/gemini-3.5-flash": { + id: "google/gemini-3.5-flash", + name: "Gemini 3.5 Flash", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.5, + output: 9, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "google/gemma-4-26b-a4b-it": { + id: "google/gemma-4-26b-a4b-it", + name: "Gemma 4 26B A4B IT", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.13, + output: 0.39999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, + "google/gemma-4-31b-it": { + id: "google/gemma-4-31b-it", + name: "Gemma 4 31B IT", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.14, + output: 0.39999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, + "inception/mercury-2": { + id: "inception/mercury-2", + name: "Mercury 2", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.25, + output: 0.75, + cacheRead: 0.024999999999999998, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "inception/mercury-coder-small": { + id: "inception/mercury-coder-small", + name: "Mercury Coder Small Beta", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text"], + cost: { + input: 0.25, + output: 1, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32000, + maxTokens: 16384, + } satisfies Model<"anthropic-messages">, + "kwaipilot/kat-coder-pro-v2": { + id: "kwaipilot/kat-coder-pro-v2", + name: "Kat Coder Pro V2", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"anthropic-messages">, + "meituan/longcat-flash-chat": { + id: "meituan/longcat-flash-chat", + name: "LongCat Flash Chat", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 100000, + } satisfies Model<"anthropic-messages">, + "meta/llama-3.1-70b": { + id: "meta/llama-3.1-70b", + name: "Llama 3.1 70B Instruct", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text"], + cost: { + input: 0.72, + output: 0.72, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 8192, + } satisfies Model<"anthropic-messages">, + "meta/llama-3.1-8b": { + id: "meta/llama-3.1-8b", + name: "Llama 3.1 8B Instruct", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text"], + cost: { + input: 0.22, + output: 0.22, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 8192, + } satisfies Model<"anthropic-messages">, + "meta/llama-3.2-11b": { + id: "meta/llama-3.2-11b", + name: "Llama 3.2 11B Vision Instruct", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.16, + output: 0.16, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 8192, + } satisfies Model<"anthropic-messages">, + "meta/llama-3.2-90b": { + id: "meta/llama-3.2-90b", + name: "Llama 3.2 90B Vision Instruct", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.72, + output: 0.72, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 8192, + } satisfies Model<"anthropic-messages">, + "meta/llama-3.3-70b": { + id: "meta/llama-3.3-70b", + name: "Llama 3.3 70B Instruct", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text"], + cost: { + input: 0.72, + output: 0.72, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 8192, + } satisfies Model<"anthropic-messages">, + "meta/llama-4-maverick": { + id: "meta/llama-4-maverick", + name: "Llama 4 Maverick 17B Instruct", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.24, + output: 0.9700000000000001, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 8192, + } satisfies Model<"anthropic-messages">, + "meta/llama-4-scout": { + id: "meta/llama-4-scout", + name: "Llama 4 Scout 17B Instruct", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.16999999999999998, + output: 0.66, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 8192, + } satisfies Model<"anthropic-messages">, + "minimax/minimax-m2": { + id: "minimax/minimax-m2", + name: "MiniMax M2", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.03, + cacheWrite: 0.375, + }, + contextWindow: 205000, + maxTokens: 205000, + } satisfies Model<"anthropic-messages">, + "minimax/minimax-m2.1": { + id: "minimax/minimax-m2.1", + name: "MiniMax M2.1", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.03, + cacheWrite: 0.375, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, + "minimax/minimax-m2.1-lightning": { + id: "minimax/minimax-m2.1-lightning", + name: "MiniMax M2.1 Lightning", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 2.4, + cacheRead: 0.03, + cacheWrite: 0.375, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, + "minimax/minimax-m2.5": { + id: "minimax/minimax-m2.5", + name: "MiniMax M2.5", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.03, + cacheWrite: 0.375, + }, + contextWindow: 204800, + maxTokens: 131000, + } satisfies Model<"anthropic-messages">, + "minimax/minimax-m2.5-highspeed": { + id: "minimax/minimax-m2.5-highspeed", + name: "MiniMax M2.5 High Speed", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.6, + output: 2.4, + cacheRead: 0.03, + cacheWrite: 0.375, + }, + contextWindow: 204800, + maxTokens: 131000, + } satisfies Model<"anthropic-messages">, + "minimax/minimax-m2.7": { + id: "minimax/minimax-m2.7", + name: "MiniMax M2.7", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0.375, + }, + contextWindow: 204800, + maxTokens: 131000, + } satisfies Model<"anthropic-messages">, + "minimax/minimax-m2.7-highspeed": { + id: "minimax/minimax-m2.7-highspeed", + name: "MiniMax M2.7 High Speed", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.6, + output: 2.4, + cacheRead: 0.06, + cacheWrite: 0.375, + }, + contextWindow: 204800, + maxTokens: 131100, + } satisfies Model<"anthropic-messages">, + "mistral/codestral": { + id: "mistral/codestral", + name: "Mistral Codestral", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text"], + cost: { + input: 0.3, + output: 0.8999999999999999, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4000, + } satisfies Model<"anthropic-messages">, + "mistral/devstral-2": { + id: "mistral/devstral-2", + name: "Devstral 2", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text"], + cost: { + input: 0.39999999999999997, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"anthropic-messages">, + "mistral/devstral-small": { + id: "mistral/devstral-small", + name: "Devstral Small 1.1", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text"], + cost: { + input: 0.09999999999999999, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "mistral/devstral-small-2": { + id: "mistral/devstral-small-2", + name: "Devstral Small 2", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text"], + cost: { + input: 0.09999999999999999, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"anthropic-messages">, + "mistral/ministral-3b": { + id: "mistral/ministral-3b", + name: "Ministral 3B", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text"], + cost: { + input: 0.09999999999999999, + output: 0.09999999999999999, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4000, + } satisfies Model<"anthropic-messages">, + "mistral/ministral-8b": { + id: "mistral/ministral-8b", + name: "Ministral 8B", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text"], + cost: { + input: 0.15, + output: 0.15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4000, + } satisfies Model<"anthropic-messages">, + "mistral/mistral-medium": { + id: "mistral/mistral-medium", + name: "Mistral Medium 3.1", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.39999999999999997, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "mistral/mistral-medium-3.5": { + id: "mistral/mistral-medium-3.5", + name: "Mistral Medium Latest", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 1.5, + output: 7.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"anthropic-messages">, + "mistral/mistral-small": { + id: "mistral/mistral-small", + name: "Mistral Small", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.09999999999999999, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32000, + maxTokens: 4000, + } satisfies Model<"anthropic-messages">, + "mistral/pixtral-12b": { + id: "mistral/pixtral-12b", + name: "Pixtral 12B 2409", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4000, + } satisfies Model<"anthropic-messages">, + "mistral/pixtral-large": { + id: "mistral/pixtral-large", + name: "Pixtral Large", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2, + output: 6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4000, + } satisfies Model<"anthropic-messages">, + "moonshotai/kimi-k2": { + id: "moonshotai/kimi-k2", + name: "Kimi K2 Instruct", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text"], + cost: { + input: 0.5700000000000001, + output: 2.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, + "moonshotai/kimi-k2-thinking": { + id: "moonshotai/kimi-k2-thinking", + name: "Kimi K2 Thinking", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.6, + output: 2.5, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 262114, + maxTokens: 262114, + } satisfies Model<"anthropic-messages">, + "moonshotai/kimi-k2-thinking-turbo": { + id: "moonshotai/kimi-k2-thinking-turbo", + name: "Kimi K2 Thinking Turbo", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 1.15, + output: 8, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 262114, + maxTokens: 262114, + } satisfies Model<"anthropic-messages">, + "moonshotai/kimi-k2-turbo": { + id: "moonshotai/kimi-k2-turbo", + name: "Kimi K2 Turbo", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text"], + cost: { + input: 1.15, + output: 8, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 16384, + } satisfies Model<"anthropic-messages">, + "moonshotai/kimi-k2.5": { + id: "moonshotai/kimi-k2.5", + name: "Kimi K2.5", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.6, + output: 3, + cacheRead: 0.09999999999999999, + cacheWrite: 0, + }, + contextWindow: 262114, + maxTokens: 262114, + } satisfies Model<"anthropic-messages">, + "moonshotai/kimi-k2.6": { + id: "moonshotai/kimi-k2.6", + name: "Kimi K2.6", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.95, + output: 4, + cacheRead: 0.16, + cacheWrite: 0, + }, + contextWindow: 262000, + maxTokens: 262000, + } satisfies Model<"anthropic-messages">, + "nvidia/nemotron-nano-12b-v2-vl": { + id: "nvidia/nemotron-nano-12b-v2-vl", + name: "Nvidia Nemotron Nano 12B V2 VL", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.19999999999999998, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, + "nvidia/nemotron-nano-9b-v2": { + id: "nvidia/nemotron-nano-9b-v2", + name: "Nvidia Nemotron Nano 9B V2", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.06, + output: 0.22999999999999998, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, + "openai/gpt-4-turbo": { + id: "openai/gpt-4-turbo", + name: "GPT-4 Turbo", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 10, + output: 30, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"anthropic-messages">, + "openai/gpt-4.1": { + id: "openai/gpt-4.1", + name: "GPT-4.1", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2, + output: 8, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 1047576, + maxTokens: 32768, + } satisfies Model<"anthropic-messages">, + "openai/gpt-4.1-mini": { + id: "openai/gpt-4.1-mini", + name: "GPT-4.1 mini", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.39999999999999997, + output: 1.5999999999999999, + cacheRead: 0.09999999999999999, + cacheWrite: 0, + }, + contextWindow: 1047576, + maxTokens: 32768, + } satisfies Model<"anthropic-messages">, + "openai/gpt-4.1-nano": { + id: "openai/gpt-4.1-nano", + name: "GPT-4.1 nano", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.09999999999999999, + output: 0.39999999999999997, + cacheRead: 0.024999999999999998, + cacheWrite: 0, + }, + contextWindow: 1047576, + maxTokens: 32768, + } satisfies Model<"anthropic-messages">, + "openai/gpt-4o": { + id: "openai/gpt-4o", + name: "GPT-4o", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2.5, + output: 10, + cacheRead: 1.25, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"anthropic-messages">, + "openai/gpt-4o-mini": { + id: "openai/gpt-4o-mini", + name: "GPT-4o mini", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"anthropic-messages">, + "openai/gpt-5": { + id: "openai/gpt-5", + name: "GPT-5", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "openai/gpt-5-chat": { + id: "openai/gpt-5-chat", + name: "GPT 5 Chat", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"anthropic-messages">, + "openai/gpt-5-codex": { + id: "openai/gpt-5-codex", + name: "GPT-5-Codex", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "openai/gpt-5-mini": { + id: "openai/gpt-5-mini", + name: "GPT-5 mini", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.25, + output: 2, + cacheRead: 0.024999999999999998, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "openai/gpt-5-nano": { + id: "openai/gpt-5-nano", + name: "GPT-5 nano", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.049999999999999996, + output: 0.39999999999999997, + cacheRead: 0.005, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "openai/gpt-5-pro": { + id: "openai/gpt-5-pro", + name: "GPT-5 pro", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 120, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 272000, + } satisfies Model<"anthropic-messages">, + "openai/gpt-5.1-codex": { + id: "openai/gpt-5.1-codex", + name: "GPT-5.1-Codex", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "openai/gpt-5.1-codex-max": { + id: "openai/gpt-5.1-codex-max", + name: "GPT 5.1 Codex Max", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "openai/gpt-5.1-codex-mini": { + id: "openai/gpt-5.1-codex-mini", + name: "GPT 5.1 Codex Mini", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.25, + output: 2, + cacheRead: 0.024999999999999998, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "openai/gpt-5.1-instant": { + id: "openai/gpt-5.1-instant", + name: "GPT-5.1 Instant", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"anthropic-messages">, + "openai/gpt-5.1-thinking": { + id: "openai/gpt-5.1-thinking", + name: "GPT 5.1 Thinking", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "openai/gpt-5.2": { + id: "openai/gpt-5.2", + name: "GPT 5.2", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "openai/gpt-5.2-chat": { + id: "openai/gpt-5.2-chat", + name: "GPT 5.2 Chat", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"anthropic-messages">, + "openai/gpt-5.2-codex": { + id: "openai/gpt-5.2-codex", + name: "GPT 5.2 Codex", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "openai/gpt-5.2-pro": { + id: "openai/gpt-5.2-pro", + name: "GPT 5.2 ", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 21, + output: 168, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "openai/gpt-5.3-chat": { + id: "openai/gpt-5.3-chat", + name: "GPT-5.3 Chat", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"anthropic-messages">, + "openai/gpt-5.3-codex": { + id: "openai/gpt-5.3-codex", + name: "GPT 5.3 Codex", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "openai/gpt-5.4": { + id: "openai/gpt-5.4", + name: "GPT 5.4", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 2.5, + output: 15, + cacheRead: 0.25, + cacheWrite: 0, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "openai/gpt-5.4-mini": { + id: "openai/gpt-5.4-mini", + name: "GPT 5.4 Mini", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 0.75, + output: 4.5, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "openai/gpt-5.4-nano": { + id: "openai/gpt-5.4-nano", + name: "GPT 5.4 Nano", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 0.19999999999999998, + output: 1.25, + cacheRead: 0.02, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "openai/gpt-5.4-pro": { + id: "openai/gpt-5.4-pro", + name: "GPT 5.4 Pro", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 30, + output: 180, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "openai/gpt-5.5": { + id: "openai/gpt-5.5", + name: "GPT 5.5", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 30, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "openai/gpt-5.5-pro": { + id: "openai/gpt-5.5-pro", + name: "GPT 5.5 Pro", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 30, + output: 180, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "openai/gpt-oss-20b": { + id: "openai/gpt-oss-20b", + name: "GPT OSS 20B", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.049999999999999996, + output: 0.19999999999999998, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"anthropic-messages">, + "openai/gpt-oss-safeguard-20b": { + id: "openai/gpt-oss-safeguard-20b", + name: "GPT OSS Safeguard 20B", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.075, + output: 0.3, + cacheRead: 0.037, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 65536, + } satisfies Model<"anthropic-messages">, + "openai/o1": { + id: "openai/o1", + name: "o1", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 60, + cacheRead: 7.5, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"anthropic-messages">, + "openai/o3": { + id: "openai/o3", + name: "o3", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 8, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"anthropic-messages">, + "openai/o3-deep-research": { + id: "openai/o3-deep-research", + name: "o3-deep-research", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 10, + output: 40, + cacheRead: 2.5, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"anthropic-messages">, + "openai/o3-mini": { + id: "openai/o3-mini", + name: "o3-mini", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 1.1, + output: 4.4, + cacheRead: 0.55, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"anthropic-messages">, + "openai/o3-pro": { + id: "openai/o3-pro", + name: "o3 Pro", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 20, + output: 80, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"anthropic-messages">, + "openai/o4-mini": { + id: "openai/o4-mini", + name: "o4-mini", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.1, + output: 4.4, + cacheRead: 0.275, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"anthropic-messages">, + "perplexity/sonar": { + id: "perplexity/sonar", + name: "Sonar", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 127000, + maxTokens: 8000, + } satisfies Model<"anthropic-messages">, + "perplexity/sonar-pro": { + id: "perplexity/sonar-pro", + name: "Sonar Pro", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 8000, + } satisfies Model<"anthropic-messages">, + "xai/grok-4.1-fast-non-reasoning": { + id: "xai/grok-4.1-fast-non-reasoning", + name: "Grok 4.1 Fast Non-Reasoning", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.19999999999999998, + output: 0.5, + cacheRead: 0.049999999999999996, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 1000000, + } satisfies Model<"anthropic-messages">, + "xai/grok-4.1-fast-reasoning": { + id: "xai/grok-4.1-fast-reasoning", + name: "Grok 4.1 Fast Reasoning", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.19999999999999998, + output: 0.5, + cacheRead: 0.049999999999999996, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 1000000, + } satisfies Model<"anthropic-messages">, + "xai/grok-4.20-multi-agent": { + id: "xai/grok-4.20-multi-agent", + name: "Grok 4.20 Multi-Agent", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 2.5, + cacheRead: 0.19999999999999998, + cacheWrite: 0, + }, + contextWindow: 2000000, + maxTokens: 2000000, + } satisfies Model<"anthropic-messages">, + "xai/grok-4.20-multi-agent-beta": { + id: "xai/grok-4.20-multi-agent-beta", + name: "Grok 4.20 Multi Agent Beta", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 2.5, + cacheRead: 0.19999999999999998, + cacheWrite: 0, + }, + contextWindow: 2000000, + maxTokens: 2000000, + } satisfies Model<"anthropic-messages">, + "xai/grok-4.20-non-reasoning": { + id: "xai/grok-4.20-non-reasoning", + name: "Grok 4.20 Non-Reasoning", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 1.25, + output: 2.5, + cacheRead: 0.19999999999999998, + cacheWrite: 0, + }, + contextWindow: 2000000, + maxTokens: 2000000, + } satisfies Model<"anthropic-messages">, + "xai/grok-4.20-non-reasoning-beta": { + id: "xai/grok-4.20-non-reasoning-beta", + name: "Grok 4.20 Beta Non-Reasoning", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 1.25, + output: 2.5, + cacheRead: 0.19999999999999998, + cacheWrite: 0, + }, + contextWindow: 2000000, + maxTokens: 2000000, + } satisfies Model<"anthropic-messages">, + "xai/grok-4.20-reasoning": { + id: "xai/grok-4.20-reasoning", + name: "Grok 4.20 Reasoning", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 2.5, + cacheRead: 0.19999999999999998, + cacheWrite: 0, + }, + contextWindow: 2000000, + maxTokens: 2000000, + } satisfies Model<"anthropic-messages">, + "xai/grok-4.20-reasoning-beta": { + id: "xai/grok-4.20-reasoning-beta", + name: "Grok 4.20 Beta Reasoning", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 2.5, + cacheRead: 0.19999999999999998, + cacheWrite: 0, + }, + contextWindow: 2000000, + maxTokens: 2000000, + } satisfies Model<"anthropic-messages">, + "xai/grok-4.3": { + id: "xai/grok-4.3", + name: "Grok 4.3", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 2.5, + cacheRead: 0.19999999999999998, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 1000000, + } satisfies Model<"anthropic-messages">, + "xai/grok-build-0.1": { + id: "xai/grok-build-0.1", + name: "Grok Build 0.1", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 2, + cacheRead: 0.19999999999999998, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"anthropic-messages">, + "xiaomi/mimo-v2-flash": { + id: "xiaomi/mimo-v2-flash", + name: "MiMo V2 Flash", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.09999999999999999, + output: 0.3, + cacheRead: 0.01, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32000, + } satisfies Model<"anthropic-messages">, + "xiaomi/mimo-v2-pro": { + id: "xiaomi/mimo-v2-pro", + name: "MiMo V2 Pro", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 1, + output: 3, + cacheRead: 0.19999999999999998, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "xiaomi/mimo-v2.5": { + id: "xiaomi/mimo-v2.5", + name: "MiMo M2.5", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.39999999999999997, + output: 2, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 1050000, + maxTokens: 131100, + } satisfies Model<"anthropic-messages">, + "xiaomi/mimo-v2.5-pro": { + id: "xiaomi/mimo-v2.5-pro", + name: "MiMo V2.5 Pro", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 3, + cacheRead: 0.19999999999999998, + cacheWrite: 0, + }, + contextWindow: 1050000, + maxTokens: 131000, + } satisfies Model<"anthropic-messages">, + "zai/glm-4.5": { + id: "zai/glm-4.5", + name: "GLM-4.5", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.6, + output: 2.2, + cacheRead: 0.11, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 96000, + } satisfies Model<"anthropic-messages">, + "zai/glm-4.5-air": { + id: "zai/glm-4.5-air", + name: "GLM 4.5 Air", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.19999999999999998, + output: 1.1, + cacheRead: 0.03, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 96000, + } satisfies Model<"anthropic-messages">, + "zai/glm-4.5v": { + id: "zai/glm-4.5v", + name: "GLM 4.5V", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.6, + output: 1.7999999999999998, + cacheRead: 0.11, + cacheWrite: 0, + }, + contextWindow: 66000, + maxTokens: 16000, + } satisfies Model<"anthropic-messages">, + "zai/glm-4.6": { + id: "zai/glm-4.6", + name: "GLM 4.6", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.6, + output: 2.2, + cacheRead: 0.11, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 96000, + } satisfies Model<"anthropic-messages">, + "zai/glm-4.6v": { + id: "zai/glm-4.6v", + name: "GLM-4.6V", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 0.8999999999999999, + cacheRead: 0.049999999999999996, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 24000, + } satisfies Model<"anthropic-messages">, + "zai/glm-4.6v-flash": { + id: "zai/glm-4.6v-flash", + name: "GLM-4.6V-Flash", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 24000, + } satisfies Model<"anthropic-messages">, + "zai/glm-4.7": { + id: "zai/glm-4.7", + name: "GLM 4.7", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 2.25, + output: 2.75, + cacheRead: 2.25, + cacheWrite: 0, + }, + contextWindow: 131000, + maxTokens: 40000, + } satisfies Model<"anthropic-messages">, + "zai/glm-4.7-flash": { + id: "zai/glm-4.7-flash", + name: "GLM 4.7 Flash", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.07, + output: 0.39999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 131000, + } satisfies Model<"anthropic-messages">, + "zai/glm-4.7-flashx": { + id: "zai/glm-4.7-flashx", + name: "GLM 4.7 FlashX", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.06, + output: 0.39999999999999997, + cacheRead: 0.01, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "zai/glm-5": { + id: "zai/glm-5", + name: "GLM 5", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 1, + output: 3.1999999999999997, + cacheRead: 0.19999999999999998, + cacheWrite: 0, + }, + contextWindow: 202800, + maxTokens: 131100, + } satisfies Model<"anthropic-messages">, + "zai/glm-5-turbo": { + id: "zai/glm-5-turbo", + name: "GLM 5 Turbo", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 1.2, + output: 4, + cacheRead: 0.24, + cacheWrite: 0, + }, + contextWindow: 202800, + maxTokens: 131100, + } satisfies Model<"anthropic-messages">, + "zai/glm-5.1": { + id: "zai/glm-5.1", + name: "GLM 5.1", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 1.4, + output: 4.4, + cacheRead: 0.26, + cacheWrite: 0, + }, + contextWindow: 202800, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "zai/glm-5v-turbo": { + id: "zai/glm-5v-turbo", + name: "GLM 5V Turbo", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.2, + output: 4, + cacheRead: 0.24, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + }, + "xai": { + "grok-3": { + id: "grok-3", + name: "Grok 3", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 3, + output: 15, + cacheRead: 0.75, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "grok-3-fast": { + id: "grok-3-fast", + name: "Grok 3 Fast", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 5, + output: 25, + cacheRead: 1.25, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "grok-4.20-0309-non-reasoning": { + id: "grok-4.20-0309-non-reasoning", + name: "Grok 4.20 (Non-Reasoning)", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 1.25, + output: 2.5, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 2000000, + maxTokens: 30000, + } satisfies Model<"openai-completions">, + "grok-4.20-0309-reasoning": { + id: "grok-4.20-0309-reasoning", + name: "Grok 4.20 (Reasoning)", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 2.5, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 2000000, + maxTokens: 30000, + } satisfies Model<"openai-completions">, + "grok-4.3": { + id: "grok-4.3", + name: "Grok 4.3", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 2.5, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 30000, + } satisfies Model<"openai-completions">, + "grok-build-0.1": { + id: "grok-build-0.1", + name: "Grok Build 0.1", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 2, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"openai-completions">, + "grok-code-fast-1": { + id: "grok-code-fast-1", + name: "Grok Code Fast 1", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.2, + output: 1.5, + cacheRead: 0.02, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + }, + "xiaomi": { + "mimo-v2-flash": { + id: "mimo-v2-flash", + name: "MiMo-V2-Flash", + api: "openai-completions", + provider: "xiaomi", + baseUrl: "https://api.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text"], + cost: { + input: 0.1, + output: 0.3, + cacheRead: 0.01, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "mimo-v2-omni": { + id: "mimo-v2-omni", + name: "MiMo-V2-Omni", + api: "openai-completions", + provider: "xiaomi", + baseUrl: "https://api.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "mimo-v2-pro": { + id: "mimo-v2-pro", + name: "MiMo-V2-Pro", + api: "openai-completions", + provider: "xiaomi", + baseUrl: "https://api.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text"], + cost: { + input: 1, + output: 3, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "mimo-v2.5": { + id: "mimo-v2.5", + name: "MiMo-V2.5", + api: "openai-completions", + provider: "xiaomi", + baseUrl: "https://api.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "mimo-v2.5-pro": { + id: "mimo-v2.5-pro", + name: "MiMo-V2.5-Pro", + api: "openai-completions", + provider: "xiaomi", + baseUrl: "https://api.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text"], + cost: { + input: 1, + output: 3, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + }, + "xiaomi-token-plan-ams": { + "mimo-v2-flash": { + id: "mimo-v2-flash", + name: "MiMo-V2-Flash", + api: "openai-completions", + provider: "xiaomi-token-plan-ams", + baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text"], + cost: { + input: 0.1, + output: 0.3, + cacheRead: 0.01, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "mimo-v2-omni": { + id: "mimo-v2-omni", + name: "MiMo-V2-Omni", + api: "openai-completions", + provider: "xiaomi-token-plan-ams", + baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "mimo-v2-pro": { + id: "mimo-v2-pro", + name: "MiMo-V2-Pro", + api: "openai-completions", + provider: "xiaomi-token-plan-ams", + baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text"], + cost: { + input: 1, + output: 3, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "mimo-v2.5": { + id: "mimo-v2.5", + name: "MiMo-V2.5", + api: "openai-completions", + provider: "xiaomi-token-plan-ams", + baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "mimo-v2.5-pro": { + id: "mimo-v2.5-pro", + name: "MiMo-V2.5-Pro", + api: "openai-completions", + provider: "xiaomi-token-plan-ams", + baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text"], + cost: { + input: 1, + output: 3, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + }, + "xiaomi-token-plan-cn": { + "mimo-v2-flash": { + id: "mimo-v2-flash", + name: "MiMo-V2-Flash", + api: "openai-completions", + provider: "xiaomi-token-plan-cn", + baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text"], + cost: { + input: 0.1, + output: 0.3, + cacheRead: 0.01, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "mimo-v2-omni": { + id: "mimo-v2-omni", + name: "MiMo-V2-Omni", + api: "openai-completions", + provider: "xiaomi-token-plan-cn", + baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "mimo-v2-pro": { + id: "mimo-v2-pro", + name: "MiMo-V2-Pro", + api: "openai-completions", + provider: "xiaomi-token-plan-cn", + baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text"], + cost: { + input: 1, + output: 3, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "mimo-v2.5": { + id: "mimo-v2.5", + name: "MiMo-V2.5", + api: "openai-completions", + provider: "xiaomi-token-plan-cn", + baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "mimo-v2.5-pro": { + id: "mimo-v2.5-pro", + name: "MiMo-V2.5-Pro", + api: "openai-completions", + provider: "xiaomi-token-plan-cn", + baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text"], + cost: { + input: 1, + output: 3, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + }, + "xiaomi-token-plan-sgp": { + "mimo-v2-flash": { + id: "mimo-v2-flash", + name: "MiMo-V2-Flash", + api: "openai-completions", + provider: "xiaomi-token-plan-sgp", + baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text"], + cost: { + input: 0.1, + output: 0.3, + cacheRead: 0.01, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "mimo-v2-omni": { + id: "mimo-v2-omni", + name: "MiMo-V2-Omni", + api: "openai-completions", + provider: "xiaomi-token-plan-sgp", + baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "mimo-v2-pro": { + id: "mimo-v2-pro", + name: "MiMo-V2-Pro", + api: "openai-completions", + provider: "xiaomi-token-plan-sgp", + baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text"], + cost: { + input: 1, + output: 3, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "mimo-v2.5": { + id: "mimo-v2.5", + name: "MiMo-V2.5", + api: "openai-completions", + provider: "xiaomi-token-plan-sgp", + baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "mimo-v2.5-pro": { + id: "mimo-v2.5-pro", + name: "MiMo-V2.5-Pro", + api: "openai-completions", + provider: "xiaomi-token-plan-sgp", + baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text"], + cost: { + input: 1, + output: 3, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + }, + "zai": { + "glm-4.5-air": { + id: "glm-4.5-air", + name: "GLM-4.5-Air", + api: "openai-completions", + provider: "zai", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai"}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 98304, + } satisfies Model<"openai-completions">, + "glm-4.7": { + id: "glm-4.7", + name: "GLM-4.7", + api: "openai-completions", + provider: "zai", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","zaiToolStream":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "glm-5-turbo": { + id: "glm-5-turbo", + name: "GLM-5-Turbo", + api: "openai-completions", + provider: "zai", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","zaiToolStream":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "glm-5.1": { + id: "glm-5.1", + name: "GLM-5.1", + api: "openai-completions", + provider: "zai", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","zaiToolStream":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "glm-5v-turbo": { + id: "glm-5v-turbo", + name: "GLM-5V-Turbo", + api: "openai-completions", + provider: "zai", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","zaiToolStream":true}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + }, +} as const; diff --git a/emain/ai/models.ts b/emain/ai/models.ts new file mode 100644 index 000000000..f6e757b42 --- /dev/null +++ b/emain/ai/models.ts @@ -0,0 +1,92 @@ +import { MODELS } from "./models.generated"; +import type { Api, KnownProvider, Model, ModelThinkingLevel, Usage } from "./types"; + +const modelRegistry: Map<string, Map<string, Model<Api>>> = new Map(); + +// Initialize registry from MODELS on module load +for (const [provider, models] of Object.entries(MODELS)) { + const providerModels = new Map<string, Model<Api>>(); + for (const [id, model] of Object.entries(models)) { + providerModels.set(id, model as Model<Api>); + } + modelRegistry.set(provider, providerModels); +} + +type ModelApi< + TProvider extends KnownProvider, + TModelId extends keyof (typeof MODELS)[TProvider], +> = (typeof MODELS)[TProvider][TModelId] extends { api: infer TApi } ? (TApi extends Api ? TApi : never) : never; + +export function getModel<TProvider extends KnownProvider, TModelId extends keyof (typeof MODELS)[TProvider]>( + provider: TProvider, + modelId: TModelId, +): Model<ModelApi<TProvider, TModelId>> { + const providerModels = modelRegistry.get(provider); + return providerModels?.get(modelId as string) as Model<ModelApi<TProvider, TModelId>>; +} + +export function getProviders(): KnownProvider[] { + return Array.from(modelRegistry.keys()) as KnownProvider[]; +} + +export function getModels<TProvider extends KnownProvider>( + provider: TProvider, +): Model<ModelApi<TProvider, keyof (typeof MODELS)[TProvider]>>[] { + const models = modelRegistry.get(provider); + return models ? (Array.from(models.values()) as Model<ModelApi<TProvider, keyof (typeof MODELS)[TProvider]>>[]) : []; +} + +export function calculateCost<TApi extends Api>(model: Model<TApi>, usage: Usage): Usage["cost"] { + usage.cost.input = (model.cost.input / 1000000) * usage.input; + usage.cost.output = (model.cost.output / 1000000) * usage.output; + usage.cost.cacheRead = (model.cost.cacheRead / 1000000) * usage.cacheRead; + usage.cost.cacheWrite = (model.cost.cacheWrite / 1000000) * usage.cacheWrite; + usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite; + return usage.cost; +} + +const EXTENDED_THINKING_LEVELS: ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"]; + +export function getSupportedThinkingLevels<TApi extends Api>(model: Model<TApi>): ModelThinkingLevel[] { + if (!model.reasoning) return ["off"]; + + return EXTENDED_THINKING_LEVELS.filter((level) => { + const mapped = model.thinkingLevelMap?.[level]; + if (mapped === null) return false; + if (level === "xhigh") return mapped !== undefined; + return true; + }); +} + +export function clampThinkingLevel<TApi extends Api>( + model: Model<TApi>, + level: ModelThinkingLevel, +): ModelThinkingLevel { + const availableLevels = getSupportedThinkingLevels(model); + if (availableLevels.includes(level)) return level; + + const requestedIndex = EXTENDED_THINKING_LEVELS.indexOf(level); + if (requestedIndex === -1) return availableLevels[0] ?? "off"; + + for (let i = requestedIndex; i < EXTENDED_THINKING_LEVELS.length; i++) { + const candidate = EXTENDED_THINKING_LEVELS[i]; + if (availableLevels.includes(candidate)) return candidate; + } + for (let i = requestedIndex - 1; i >= 0; i--) { + const candidate = EXTENDED_THINKING_LEVELS[i]; + if (availableLevels.includes(candidate)) return candidate; + } + return availableLevels[0] ?? "off"; +} + +/** + * Check if two models are equal by comparing both their id and provider. + * Returns false if either model is null or undefined. + */ +export function modelsAreEqual<TApi extends Api>( + a: Model<TApi> | null | undefined, + b: Model<TApi> | null | undefined, +): boolean { + if (!a || !b) return false; + return a.id === b.id && a.provider === b.provider; +} diff --git a/emain/ai/oauth.ts b/emain/ai/oauth.ts new file mode 100644 index 000000000..b839c5e99 --- /dev/null +++ b/emain/ai/oauth.ts @@ -0,0 +1 @@ +export * from "./utils/oauth/index"; diff --git a/emain/ai/providers/anthropic.ts b/emain/ai/providers/anthropic.ts new file mode 100644 index 000000000..e1ec0fdf6 --- /dev/null +++ b/emain/ai/providers/anthropic.ts @@ -0,0 +1,1212 @@ +import Anthropic from "@anthropic-ai/sdk"; +import type { + CacheControlEphemeral, + ContentBlockParam, + MessageCreateParamsStreaming, + MessageParam, + RawMessageStreamEvent, +} from "@anthropic-ai/sdk/resources/messages.js"; +import { getEnvApiKey } from "../env-api-keys"; +import { calculateCost } from "../models"; +import type { + AnthropicMessagesCompat, + Api, + AssistantMessage, + CacheRetention, + Context, + ImageContent, + Message, + Model, + SimpleStreamOptions, + StopReason, + StreamFunction, + StreamOptions, + TextContent, + ThinkingContent, + Tool, + ToolCall, + ToolResultMessage, +} from "../types"; +import { AssistantMessageEventStream } from "../utils/event-stream"; +import { headersToRecord } from "../utils/headers"; +import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse"; +import { sanitizeSurrogates } from "../utils/sanitize-unicode"; + +import { resolveCloudflareBaseUrl } from "./cloudflare"; +import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers"; +import { adjustMaxTokensForThinking, buildBaseOptions } from "./simple-options"; +import { transformMessages } from "./transform-messages"; + +/** + * Resolve cache retention preference. + * Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility. + */ +function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention { + if (cacheRetention) { + return cacheRetention; + } + if (typeof process !== "undefined" && process.env.PI_CACHE_RETENTION === "long") { + return "long"; + } + return "short"; +} + +function getCacheControl( + model: Model<"anthropic-messages">, + cacheRetention?: CacheRetention, +): { retention: CacheRetention; cacheControl?: CacheControlEphemeral } { + const retention = resolveCacheRetention(cacheRetention); + if (retention === "none") { + return { retention }; + } + const ttl = retention === "long" && getAnthropicCompat(model).supportsLongCacheRetention ? "1h" : undefined; + return { + retention, + cacheControl: { type: "ephemeral", ...(ttl && { ttl }) }, + }; +} + +// Stealth mode: Mimic Claude Code's tool naming exactly +const claudeCodeVersion = "2.1.75"; + +// Claude Code 2.x tool names (canonical casing) +// Source: https://cchistory.mariozechner.at/data/prompts-2.1.11.md +// To update: https://github.com/badlogic/cchistory +const claudeCodeTools = [ + "Read", + "Write", + "Edit", + "Bash", + "Grep", + "Glob", + "AskUserQuestion", + "EnterPlanMode", + "ExitPlanMode", + "KillShell", + "NotebookEdit", + "Skill", + "Task", + "TaskOutput", + "TodoWrite", + "WebFetch", + "WebSearch", +]; + +const ccToolLookup = new Map(claudeCodeTools.map((t) => [t.toLowerCase(), t])); + +// Convert tool name to CC canonical casing if it matches (case-insensitive) +const toClaudeCodeName = (name: string) => ccToolLookup.get(name.toLowerCase()) ?? name; +const fromClaudeCodeName = (name: string, tools?: Tool[]) => { + if (tools && tools.length > 0) { + const lowerName = name.toLowerCase(); + const matchedTool = tools.find((tool) => tool.name.toLowerCase() === lowerName); + if (matchedTool) return matchedTool.name; + } + return name; +}; + +/** + * Convert content blocks to Anthropic API format + */ +function convertContentBlocks(content: (TextContent | ImageContent)[]): + | string + | Array< + | { type: "text"; text: string } + | { + type: "image"; + source: { + type: "base64"; + media_type: "image/jpeg" | "image/png" | "image/gif" | "image/webp"; + data: string; + }; + } + > { + // If only text blocks, return as concatenated string for simplicity + const hasImages = content.some((c) => c.type === "image"); + if (!hasImages) { + return sanitizeSurrogates(content.map((c) => (c as TextContent).text).join("\n")); + } + + // If we have images, convert to content block array + const blocks = content.map((block) => { + if (block.type === "text") { + return { + type: "text" as const, + text: sanitizeSurrogates(block.text), + }; + } + return { + type: "image" as const, + source: { + type: "base64" as const, + media_type: block.mimeType as "image/jpeg" | "image/png" | "image/gif" | "image/webp", + data: block.data, + }, + }; + }); + + // If only images (no text), add placeholder text block + const hasText = blocks.some((b) => b.type === "text"); + if (!hasText) { + blocks.unshift({ + type: "text" as const, + text: "(see attached image)", + }); + } + + return blocks; +} + +export type AnthropicEffort = "low" | "medium" | "high" | "xhigh" | "max"; + +export type AnthropicThinkingDisplay = "summarized" | "omitted"; + +const FINE_GRAINED_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14"; +const INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14"; + +function getAnthropicCompat( + model: Model<"anthropic-messages">, +): Required<Omit<AnthropicMessagesCompat, "forceAdaptiveThinking">> { + // Auto-detect session affinity and cache control support from provider + const isFireworks = model.provider === "fireworks"; + const isCloudflareAiGatewayAnthropic = + model.provider === "cloudflare-ai-gateway" && model.baseUrl.includes("anthropic"); + return { + supportsEagerToolInputStreaming: model.compat?.supportsEagerToolInputStreaming ?? !isFireworks, + supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? !isFireworks, + sendSessionAffinityHeaders: + model.compat?.sendSessionAffinityHeaders ?? !!(isFireworks || isCloudflareAiGatewayAnthropic), + supportsCacheControlOnTools: model.compat?.supportsCacheControlOnTools ?? !isFireworks, + }; +} + +export interface AnthropicOptions extends StreamOptions { + /** + * Enable extended thinking. + * For adaptive thinking models: the model decides when/how much to think. + * For older models: uses budget-based thinking with thinkingBudgetTokens. + * Default: undefined (thinking is omitted unless `streamSimpleAnthropic()` maps + * a simple reasoning level to this option, or callers set it explicitly). + */ + thinkingEnabled?: boolean; + /** + * Token budget for extended thinking (older models only). + * Ignored for adaptive thinking models. + * Default: 1024 when `thinkingEnabled` is true and no budget is provided. + */ + thinkingBudgetTokens?: number; + /** + * Effort level for adaptive thinking models. + * Controls how much thinking Claude allocates: + * - "max": Always thinks with no constraints (Opus 4.6 only) + * - "xhigh": Highest reasoning level (Opus 4.7) + * - "high": Always thinks, deep reasoning + * - "medium": Moderate thinking, may skip for simple queries + * - "low": Minimal thinking, skips for simple tasks + * Ignored for older models. + * Default: omitted unless `streamSimpleAnthropic()` maps a simple reasoning + * level to this option. + */ + effort?: AnthropicEffort; + /** + * Controls how thinking content is returned in API responses. + * - "summarized": Thinking blocks contain summarized thinking text. + * - "omitted": Thinking blocks return an empty thinking field; the encrypted + * signature still travels back for multi-turn continuity. Use for faster + * time-to-first-text-token when your UI does not surface thinking. + * + * Note: Anthropic's API default for Claude Opus 4.7 and Claude Mythos Preview + * is "omitted". We default to "summarized" here to keep behavior consistent + * with older Claude 4 models. Set this explicitly to "omitted" to opt in. + * Default: "summarized" when thinking is enabled. + */ + thinkingDisplay?: AnthropicThinkingDisplay; + /** + * Whether to request the interleaved thinking beta header for non-adaptive + * thinking models. Adaptive thinking models have interleaved thinking built in, + * so the header is skipped for them regardless of this setting. + * Default: true. + */ + interleavedThinking?: boolean; + /** + * Anthropic tool choice behavior. String values map to Anthropic's built-in + * choices; `{ type: "tool", name }` forces a specific tool. + * Default: omitted (Anthropic default behavior, currently equivalent to auto). + */ + toolChoice?: "auto" | "any" | "none" | { type: "tool"; name: string }; + /** + * Pre-built Anthropic client instance. When provided, skips internal client + * construction entirely. Use this to inject alternative SDK clients such as + * `AnthropicVertex` that shares the same messaging API. + */ + client?: Anthropic; +} + +function mergeHeaders(...headerSources: (Record<string, string | null> | undefined)[]): Record<string, string | null> { + const merged: Record<string, string | null> = {}; + for (const headers of headerSources) { + if (headers) { + Object.assign(merged, headers); + } + } + return merged; +} + +interface ServerSentEvent { + event: string | null; + data: string; + raw: string[]; +} + +interface SseDecoderState { + event: string | null; + data: string[]; + raw: string[]; +} + +const ANTHROPIC_MESSAGE_EVENTS: ReadonlySet<string> = new Set([ + "message_start", + "message_delta", + "message_stop", + "content_block_start", + "content_block_delta", + "content_block_stop", +]); + +function flushSseEvent(state: SseDecoderState): ServerSentEvent | null { + if (!state.event && state.data.length === 0) { + return null; + } + + const event: ServerSentEvent = { + event: state.event, + data: state.data.join("\n"), + raw: [...state.raw], + }; + state.event = null; + state.data = []; + state.raw = []; + return event; +} + +function decodeSseLine(line: string, state: SseDecoderState): ServerSentEvent | null { + if (line === "") { + return flushSseEvent(state); + } + + state.raw.push(line); + if (line.startsWith(":")) { + return null; + } + + const delimiterIndex = line.indexOf(":"); + const fieldName = delimiterIndex === -1 ? line : line.slice(0, delimiterIndex); + let value = delimiterIndex === -1 ? "" : line.slice(delimiterIndex + 1); + if (value.startsWith(" ")) { + value = value.slice(1); + } + + if (fieldName === "event") { + state.event = value; + } else if (fieldName === "data") { + state.data.push(value); + } + + return null; +} + +function nextLineBreakIndex(text: string): number { + const carriageReturnIndex = text.indexOf("\r"); + const newlineIndex = text.indexOf("\n"); + if (carriageReturnIndex === -1) { + return newlineIndex; + } + if (newlineIndex === -1) { + return carriageReturnIndex; + } + return Math.min(carriageReturnIndex, newlineIndex); +} + +function consumeLine(text: string): { line: string; rest: string } | null { + const lineBreakIndex = nextLineBreakIndex(text); + if (lineBreakIndex === -1) { + return null; + } + + let nextIndex = lineBreakIndex + 1; + if (text[lineBreakIndex] === "\r" && text[nextIndex] === "\n") { + nextIndex += 1; + } + + return { + line: text.slice(0, lineBreakIndex), + rest: text.slice(nextIndex), + }; +} + +async function* iterateSseMessages( + body: ReadableStream<Uint8Array>, + signal?: AbortSignal, +): AsyncGenerator<ServerSentEvent> { + const reader = body.getReader(); + const decoder = new TextDecoder(); + const state: SseDecoderState = { event: null, data: [], raw: [] }; + let buffer = ""; + + try { + while (true) { + if (signal?.aborted) { + throw new Error("Request was aborted"); + } + + const { value, done } = await reader.read(); + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + let consumed = consumeLine(buffer); + while (consumed) { + buffer = consumed.rest; + const event = decodeSseLine(consumed.line, state); + if (event) { + yield event; + } + consumed = consumeLine(buffer); + } + } + + buffer += decoder.decode(); + let consumed = consumeLine(buffer); + while (consumed) { + buffer = consumed.rest; + const event = decodeSseLine(consumed.line, state); + if (event) { + yield event; + } + consumed = consumeLine(buffer); + } + + if (buffer.length > 0) { + const event = decodeSseLine(buffer, state); + if (event) { + yield event; + } + } + + const trailingEvent = flushSseEvent(state); + if (trailingEvent) { + yield trailingEvent; + } + } finally { + reader.releaseLock(); + } +} + +async function* iterateAnthropicEvents( + response: Response, + signal?: AbortSignal, +): AsyncGenerator<RawMessageStreamEvent> { + if (!response.body) { + throw new Error("Attempted to iterate over an Anthropic response with no body"); + } + + let sawMessageStart = false; + let sawMessageEnd = false; + + for await (const sse of iterateSseMessages(response.body, signal)) { + if (sse.event === "error") { + throw new Error(sse.data); + } + + if (!ANTHROPIC_MESSAGE_EVENTS.has(sse.event ?? "")) { + continue; + } + + try { + const event = parseJsonWithRepair<RawMessageStreamEvent>(sse.data); + if (event.type === "message_start") { + sawMessageStart = true; + } else if (event.type === "message_stop") { + sawMessageEnd = true; + } + yield event; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Could not parse Anthropic SSE event ${sse.event}: ${message}; data=${sse.data}; raw=${sse.raw.join("\\n")}`, + ); + } + } + + if (sawMessageStart && !sawMessageEnd) { + throw new Error("Anthropic stream ended before message_stop"); + } +} + +export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOptions> = ( + model: Model<"anthropic-messages">, + context: Context, + options?: AnthropicOptions, +): AssistantMessageEventStream => { + const stream = new AssistantMessageEventStream(); + + (async () => { + const output: AssistantMessage = { + role: "assistant", + content: [], + api: model.api as Api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + + try { + let client: Anthropic; + let isOAuth: boolean; + + if (options?.client) { + client = options.client; + isOAuth = false; + } else { + const apiKey = options?.apiKey ?? getEnvApiKey(model.provider) ?? ""; + + let copilotDynamicHeaders: Record<string, string> | undefined; + if (model.provider === "github-copilot") { + const hasImages = hasCopilotVisionInput(context.messages); + copilotDynamicHeaders = buildCopilotDynamicHeaders({ + messages: context.messages, + hasImages, + }); + } + + const cacheRetention = options?.cacheRetention ?? resolveCacheRetention(); + const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId; + + const created = createClient( + model, + apiKey, + options?.interleavedThinking ?? true, + shouldUseFineGrainedToolStreamingBeta(model, context), + options?.headers, + copilotDynamicHeaders, + cacheSessionId, + ); + client = created.client; + isOAuth = created.isOAuthToken; + } + let params = buildParams(model, context, isOAuth, options); + const nextParams = await options?.onPayload?.(params, model); + if (nextParams !== undefined) { + params = nextParams as MessageCreateParamsStreaming; + } + const requestOptions = { + ...(options?.signal ? { signal: options.signal } : {}), + ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), + ...(options?.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), + }; + const response = await client.messages.create({ ...params, stream: true }, requestOptions).asResponse(); + await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model); + stream.push({ type: "start", partial: output }); + + type Block = (ThinkingContent | TextContent | (ToolCall & { partialJson: string })) & { index: number }; + const blocks = output.content as Block[]; + + for await (const event of iterateAnthropicEvents(response, options?.signal)) { + if (event.type === "message_start") { + output.responseId = event.message.id; + // Capture initial token usage from message_start event + // This ensures we have input token counts even if the stream is aborted early + output.usage.input = event.message.usage.input_tokens || 0; + output.usage.output = event.message.usage.output_tokens || 0; + output.usage.cacheRead = event.message.usage.cache_read_input_tokens || 0; + output.usage.cacheWrite = event.message.usage.cache_creation_input_tokens || 0; + // Anthropic doesn't provide total_tokens, compute from components + output.usage.totalTokens = + output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; + calculateCost(model, output.usage); + } else if (event.type === "content_block_start") { + if (event.content_block.type === "text") { + const block: Block = { + type: "text", + text: "", + index: event.index, + }; + output.content.push(block); + stream.push({ type: "text_start", contentIndex: output.content.length - 1, partial: output }); + } else if (event.content_block.type === "thinking") { + const block: Block = { + type: "thinking", + thinking: "", + thinkingSignature: "", + index: event.index, + }; + output.content.push(block); + stream.push({ type: "thinking_start", contentIndex: output.content.length - 1, partial: output }); + } else if (event.content_block.type === "redacted_thinking") { + const block: Block = { + type: "thinking", + thinking: "[Reasoning redacted]", + thinkingSignature: event.content_block.data, + redacted: true, + index: event.index, + }; + output.content.push(block); + stream.push({ type: "thinking_start", contentIndex: output.content.length - 1, partial: output }); + } else if (event.content_block.type === "tool_use") { + const block: Block = { + type: "toolCall", + id: event.content_block.id, + name: isOAuth + ? fromClaudeCodeName(event.content_block.name, context.tools) + : event.content_block.name, + arguments: (event.content_block.input as Record<string, any>) ?? {}, + partialJson: "", + index: event.index, + }; + output.content.push(block); + stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output }); + } + } else if (event.type === "content_block_delta") { + if (event.delta.type === "text_delta") { + const index = blocks.findIndex((b) => b.index === event.index); + const block = blocks[index]; + if (block && block.type === "text") { + block.text += event.delta.text; + stream.push({ + type: "text_delta", + contentIndex: index, + delta: event.delta.text, + partial: output, + }); + } + } else if (event.delta.type === "thinking_delta") { + const index = blocks.findIndex((b) => b.index === event.index); + const block = blocks[index]; + if (block && block.type === "thinking") { + block.thinking += event.delta.thinking; + stream.push({ + type: "thinking_delta", + contentIndex: index, + delta: event.delta.thinking, + partial: output, + }); + } + } else if (event.delta.type === "input_json_delta") { + const index = blocks.findIndex((b) => b.index === event.index); + const block = blocks[index]; + if (block && block.type === "toolCall") { + block.partialJson += event.delta.partial_json; + block.arguments = parseStreamingJson(block.partialJson); + stream.push({ + type: "toolcall_delta", + contentIndex: index, + delta: event.delta.partial_json, + partial: output, + }); + } + } else if (event.delta.type === "signature_delta") { + const index = blocks.findIndex((b) => b.index === event.index); + const block = blocks[index]; + if (block && block.type === "thinking") { + block.thinkingSignature = block.thinkingSignature || ""; + block.thinkingSignature += event.delta.signature; + } + } + } else if (event.type === "content_block_stop") { + const index = blocks.findIndex((b) => b.index === event.index); + const block = blocks[index]; + if (block) { + delete (block as any).index; + if (block.type === "text") { + stream.push({ + type: "text_end", + contentIndex: index, + content: block.text, + partial: output, + }); + } else if (block.type === "thinking") { + stream.push({ + type: "thinking_end", + contentIndex: index, + content: block.thinking, + partial: output, + }); + } else if (block.type === "toolCall") { + block.arguments = parseStreamingJson(block.partialJson); + // Finalize in-place and strip the scratch buffer so replay only + // carries parsed arguments. + delete (block as { partialJson?: string }).partialJson; + stream.push({ + type: "toolcall_end", + contentIndex: index, + toolCall: block, + partial: output, + }); + } + } + } else if (event.type === "message_delta") { + if (event.delta.stop_reason) { + output.stopReason = mapStopReason(event.delta.stop_reason); + } + // Only update usage fields if present (not null). + // Preserves input_tokens from message_start when proxies omit it in message_delta. + if (event.usage.input_tokens != null) { + output.usage.input = event.usage.input_tokens; + } + if (event.usage.output_tokens != null) { + output.usage.output = event.usage.output_tokens; + } + if (event.usage.cache_read_input_tokens != null) { + output.usage.cacheRead = event.usage.cache_read_input_tokens; + } + if (event.usage.cache_creation_input_tokens != null) { + output.usage.cacheWrite = event.usage.cache_creation_input_tokens; + } + // Anthropic doesn't provide total_tokens, compute from components + output.usage.totalTokens = + output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; + calculateCost(model, output.usage); + } + } + + if (options?.signal?.aborted) { + throw new Error("Request was aborted"); + } + + if (output.stopReason === "aborted" || output.stopReason === "error") { + throw new Error("An unknown error occurred"); + } + + stream.push({ type: "done", reason: output.stopReason, message: output }); + stream.end(); + } catch (error) { + for (const block of output.content) { + delete (block as { index?: number }).index; + // partialJson is only a streaming scratch buffer; never persist it. + delete (block as { partialJson?: string }).partialJson; + } + output.stopReason = options?.signal?.aborted ? "aborted" : "error"; + output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + stream.push({ type: "error", reason: output.stopReason, error: output }); + stream.end(); + } + })(); + + return stream; +}; + +/** + * Map ThinkingLevel to Anthropic effort levels for adaptive thinking. + * Note: effort "max" is only valid on Opus 4.6, while Opus 4.7 supports "xhigh". + */ +function mapThinkingLevelToEffort( + model: Model<"anthropic-messages">, + level: SimpleStreamOptions["reasoning"], +): AnthropicEffort { + const mapped = level ? model.thinkingLevelMap?.[level] : undefined; + if (typeof mapped === "string") return mapped as AnthropicEffort; + + switch (level) { + case "minimal": + case "low": + return "low"; + case "medium": + return "medium"; + case "high": + return "high"; + default: + return "high"; + } +} + +export const streamSimpleAnthropic: StreamFunction<"anthropic-messages", SimpleStreamOptions> = ( + model: Model<"anthropic-messages">, + context: Context, + options?: SimpleStreamOptions, +): AssistantMessageEventStream => { + const apiKey = options?.apiKey || getEnvApiKey(model.provider); + if (!apiKey) { + throw new Error(`No API key for provider: ${model.provider}`); + } + + const base = buildBaseOptions(model, options, apiKey); + if (!options?.reasoning) { + return streamAnthropic(model, context, { ...base, thinkingEnabled: false } satisfies AnthropicOptions); + } + + // For models with adaptive thinking: use an effort level. + // For older models: use budget-based thinking. + if (model.compat?.forceAdaptiveThinking === true) { + const effort = mapThinkingLevelToEffort(model, options.reasoning); + return streamAnthropic(model, context, { + ...base, + thinkingEnabled: true, + effort, + } satisfies AnthropicOptions); + } + + // Undefined means the caller did not request an output cap; let the helper use the model cap. + // Do not coerce to 0 here, or the thinking budget would become the entire max_tokens value. + const adjusted = adjustMaxTokensForThinking( + base.maxTokens, + model.maxTokens, + options.reasoning, + options.thinkingBudgets, + ); + + return streamAnthropic(model, context, { + ...base, + maxTokens: adjusted.maxTokens, + thinkingEnabled: true, + thinkingBudgetTokens: adjusted.thinkingBudget, + } satisfies AnthropicOptions); +}; + +function isOAuthToken(apiKey: string): boolean { + return apiKey.includes("sk-ant-oat"); +} + +function createClient( + model: Model<"anthropic-messages">, + apiKey: string, + interleavedThinking: boolean, + useFineGrainedToolStreamingBeta: boolean, + optionsHeaders?: Record<string, string>, + dynamicHeaders?: Record<string, string>, + sessionId?: string, +): { client: Anthropic; isOAuthToken: boolean } { + // Adaptive thinking models have interleaved thinking built in, so skip the beta header. + const needsInterleavedBeta = interleavedThinking && model.compat?.forceAdaptiveThinking !== true; + const betaFeatures: string[] = []; + if (useFineGrainedToolStreamingBeta) { + betaFeatures.push(FINE_GRAINED_TOOL_STREAMING_BETA); + } + if (needsInterleavedBeta) { + betaFeatures.push(INTERLEAVED_THINKING_BETA); + } + + if (model.provider === "cloudflare-ai-gateway") { + const client = new Anthropic({ + apiKey: null, + authToken: null, + baseURL: resolveCloudflareBaseUrl(model), + dangerouslyAllowBrowser: true, + defaultHeaders: mergeHeaders( + { + accept: "application/json", + "anthropic-dangerous-direct-browser-access": "true", + "cf-aig-authorization": `Bearer ${apiKey}`, + "x-api-key": null, + Authorization: null, + ...(betaFeatures.length > 0 ? { "anthropic-beta": betaFeatures.join(",") } : {}), + }, + model.headers, + optionsHeaders, + ), + }); + + return { client, isOAuthToken: false }; + } + + // Copilot: Bearer auth, selective betas. + if (model.provider === "github-copilot") { + const client = new Anthropic({ + apiKey: null, + authToken: apiKey, + baseURL: model.baseUrl, + dangerouslyAllowBrowser: true, + defaultHeaders: mergeHeaders( + { + accept: "application/json", + "anthropic-dangerous-direct-browser-access": "true", + ...(betaFeatures.length > 0 ? { "anthropic-beta": betaFeatures.join(",") } : {}), + }, + model.headers, + dynamicHeaders, + optionsHeaders, + ), + }); + + return { client, isOAuthToken: false }; + } + + // OAuth: Bearer auth, Claude Code identity headers + if (isOAuthToken(apiKey)) { + const client = new Anthropic({ + apiKey: null, + authToken: apiKey, + baseURL: model.baseUrl, + dangerouslyAllowBrowser: true, + defaultHeaders: mergeHeaders( + { + accept: "application/json", + "anthropic-dangerous-direct-browser-access": "true", + "anthropic-beta": ["claude-code-20250219", "oauth-2025-04-20", ...betaFeatures].join(","), + "user-agent": `claude-cli/${claudeCodeVersion}`, + "x-app": "cli", + }, + model.headers, + optionsHeaders, + ), + }); + + return { client, isOAuthToken: true }; + } + + // API key auth + const sessionAffinityHeaders: Record<string, string | null> = + sessionId && getAnthropicCompat(model).sendSessionAffinityHeaders ? { "x-session-affinity": sessionId } : {}; + const client = new Anthropic({ + apiKey, + authToken: null, + baseURL: model.baseUrl, + dangerouslyAllowBrowser: true, + defaultHeaders: mergeHeaders( + { + accept: "application/json", + "anthropic-dangerous-direct-browser-access": "true", + ...(betaFeatures.length > 0 ? { "anthropic-beta": betaFeatures.join(",") } : {}), + }, + sessionAffinityHeaders, + model.headers, + optionsHeaders, + ), + }); + + return { client, isOAuthToken: false }; +} + +function buildParams( + model: Model<"anthropic-messages">, + context: Context, + isOAuthToken: boolean, + options?: AnthropicOptions, +): MessageCreateParamsStreaming { + const { cacheControl } = getCacheControl(model, options?.cacheRetention); + const params: MessageCreateParamsStreaming = { + model: model.id, + messages: convertMessages(context.messages, model, isOAuthToken, cacheControl), + max_tokens: options?.maxTokens ?? model.maxTokens, + stream: true, + }; + + // For OAuth tokens, we MUST include Claude Code identity + if (isOAuthToken) { + params.system = [ + { + type: "text", + text: "You are Claude Code, Anthropic's official CLI for Claude.", + ...(cacheControl ? { cache_control: cacheControl } : {}), + }, + ]; + if (context.systemPrompt) { + params.system.push({ + type: "text", + text: sanitizeSurrogates(context.systemPrompt), + ...(cacheControl ? { cache_control: cacheControl } : {}), + }); + } + } else if (context.systemPrompt) { + // Add cache control to system prompt for non-OAuth tokens + params.system = [ + { + type: "text", + text: sanitizeSurrogates(context.systemPrompt), + ...(cacheControl ? { cache_control: cacheControl } : {}), + }, + ]; + } + + // Temperature is incompatible with extended thinking (adaptive or budget-based). + if (options?.temperature !== undefined && !options?.thinkingEnabled) { + params.temperature = options.temperature; + } + + if (context.tools && context.tools.length > 0) { + const compat = getAnthropicCompat(model); + params.tools = convertTools( + context.tools, + isOAuthToken, + compat.supportsEagerToolInputStreaming, + compat.supportsCacheControlOnTools ? cacheControl : undefined, + ); + } + + // Configure thinking mode: adaptive, budget-based, or explicitly disabled. + if (model.reasoning) { + if (options?.thinkingEnabled) { + // Default to "summarized" so Opus 4.7 and Mythos Preview behave like + // older Claude 4 models (whose API default is also "summarized"). + const display: AnthropicThinkingDisplay = options.thinkingDisplay ?? "summarized"; + if (model.compat?.forceAdaptiveThinking === true) { + // Adaptive thinking: Claude decides when and how much to think. + params.thinking = { type: "adaptive", display }; + if (options.effort) { + // The Anthropic SDK types can lag newly supported effort values such as "xhigh". + params.output_config = + options.effort === "xhigh" + ? ({ effort: options.effort } as unknown as NonNullable< + MessageCreateParamsStreaming["output_config"] + >) + : { effort: options.effort }; + } + } else { + // Budget-based thinking for older models + params.thinking = { + type: "enabled", + budget_tokens: options.thinkingBudgetTokens || 1024, + display, + }; + } + } else if (options?.thinkingEnabled === false) { + params.thinking = { type: "disabled" }; + } + } + + if (options?.metadata) { + const userId = options.metadata.user_id; + if (typeof userId === "string") { + params.metadata = { user_id: userId }; + } + } + + if (options?.toolChoice) { + if (typeof options.toolChoice === "string") { + params.tool_choice = { type: options.toolChoice }; + } else { + params.tool_choice = options.toolChoice; + } + } + + return params; +} + +// Normalize tool call IDs to match Anthropic's required pattern and length +function normalizeToolCallId(id: string): string { + return id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64); +} + +function convertMessages( + messages: Message[], + model: Model<"anthropic-messages">, + isOAuthToken: boolean, + cacheControl?: CacheControlEphemeral, +): MessageParam[] { + const params: MessageParam[] = []; + + // Transform messages for cross-provider compatibility + const transformedMessages = transformMessages(messages, model, normalizeToolCallId); + + for (let i = 0; i < transformedMessages.length; i++) { + const msg = transformedMessages[i]; + + if (msg.role === "user") { + if (typeof msg.content === "string") { + if (msg.content.trim().length > 0) { + params.push({ + role: "user", + content: sanitizeSurrogates(msg.content), + }); + } + } else { + const blocks: ContentBlockParam[] = msg.content.map((item) => { + if (item.type === "text") { + return { + type: "text", + text: sanitizeSurrogates(item.text), + }; + } else { + return { + type: "image", + source: { + type: "base64", + media_type: item.mimeType as "image/jpeg" | "image/png" | "image/gif" | "image/webp", + data: item.data, + }, + }; + } + }); + const filteredBlocks = blocks.filter((b) => { + if (b.type === "text") { + return b.text.trim().length > 0; + } + return true; + }); + if (filteredBlocks.length === 0) continue; + params.push({ + role: "user", + content: filteredBlocks, + }); + } + } else if (msg.role === "assistant") { + const blocks: ContentBlockParam[] = []; + + for (const block of msg.content) { + if (block.type === "text") { + if (block.text.trim().length === 0) continue; + blocks.push({ + type: "text", + text: sanitizeSurrogates(block.text), + }); + } else if (block.type === "thinking") { + // Redacted thinking: pass the opaque payload back as redacted_thinking + if (block.redacted) { + blocks.push({ + type: "redacted_thinking", + data: block.thinkingSignature!, + }); + continue; + } + if (block.thinking.trim().length === 0) continue; + // If thinking signature is missing/empty (e.g., from aborted stream), + // convert to plain text block without <thinking> tags to avoid API rejection + // and prevent Claude from mimicking the tags in responses + if (!block.thinkingSignature || block.thinkingSignature.trim().length === 0) { + blocks.push({ + type: "text", + text: sanitizeSurrogates(block.thinking), + }); + } else { + blocks.push({ + type: "thinking", + thinking: sanitizeSurrogates(block.thinking), + signature: block.thinkingSignature, + }); + } + } else if (block.type === "toolCall") { + blocks.push({ + type: "tool_use", + id: block.id, + name: isOAuthToken ? toClaudeCodeName(block.name) : block.name, + input: block.arguments ?? {}, + }); + } + } + if (blocks.length === 0) continue; + params.push({ + role: "assistant", + content: blocks, + }); + } else if (msg.role === "toolResult") { + // Collect all consecutive toolResult messages, needed for z.ai Anthropic endpoint + const toolResults: ContentBlockParam[] = []; + + // Add the current tool result + toolResults.push({ + type: "tool_result", + tool_use_id: msg.toolCallId, + content: convertContentBlocks(msg.content), + is_error: msg.isError, + }); + + // Look ahead for consecutive toolResult messages + let j = i + 1; + while (j < transformedMessages.length && transformedMessages[j].role === "toolResult") { + const nextMsg = transformedMessages[j] as ToolResultMessage; // We know it's a toolResult + toolResults.push({ + type: "tool_result", + tool_use_id: nextMsg.toolCallId, + content: convertContentBlocks(nextMsg.content), + is_error: nextMsg.isError, + }); + j++; + } + + // Skip the messages we've already processed + i = j - 1; + + // Add a single user message with all tool results + params.push({ + role: "user", + content: toolResults, + }); + } + } + + // Add cache_control to the last user message to cache conversation history + if (cacheControl && params.length > 0) { + const lastMessage = params[params.length - 1]; + if (lastMessage.role === "user") { + if (Array.isArray(lastMessage.content)) { + const lastBlock = lastMessage.content[lastMessage.content.length - 1]; + if ( + lastBlock && + (lastBlock.type === "text" || lastBlock.type === "image" || lastBlock.type === "tool_result") + ) { + (lastBlock as any).cache_control = cacheControl; + } + } else if (typeof lastMessage.content === "string") { + lastMessage.content = [ + { + type: "text", + text: lastMessage.content, + cache_control: cacheControl, + }, + ] as any; + } + } + } + + return params; +} + +function shouldUseFineGrainedToolStreamingBeta(model: Model<"anthropic-messages">, context: Context): boolean { + return !!context.tools?.length && !getAnthropicCompat(model).supportsEagerToolInputStreaming; +} + +function convertTools( + tools: Tool[], + isOAuthToken: boolean, + supportsEagerToolInputStreaming: boolean, + cacheControl?: CacheControlEphemeral, +): Anthropic.Messages.Tool[] { + if (!tools) return []; + + return tools.map((tool, index) => { + const schema = tool.parameters as { properties?: unknown; required?: string[] }; + + return { + name: isOAuthToken ? toClaudeCodeName(tool.name) : tool.name, + description: tool.description, + ...(supportsEagerToolInputStreaming ? { eager_input_streaming: true } : {}), + input_schema: { + type: "object", + properties: schema.properties ?? {}, + required: schema.required ?? [], + }, + ...(cacheControl && index === tools.length - 1 ? { cache_control: cacheControl } : {}), + }; + }); +} + +function mapStopReason(reason: Anthropic.Messages.StopReason | string): StopReason { + switch (reason) { + case "end_turn": + return "stop"; + case "max_tokens": + return "length"; + case "tool_use": + return "toolUse"; + case "refusal": + return "error"; + case "pause_turn": // Stop is good enough -> resubmit + return "stop"; + case "stop_sequence": + return "stop"; // We don't supply stop sequences, so this should never happen + case "sensitive": // Content flagged by safety filters (not yet in SDK types) + return "error"; + default: + // Handle unknown stop reasons gracefully (API may add new values) + throw new Error(`Unhandled stop reason: ${reason}`); + } +} diff --git a/emain/ai/providers/cloudflare.ts b/emain/ai/providers/cloudflare.ts new file mode 100644 index 000000000..5db012751 --- /dev/null +++ b/emain/ai/providers/cloudflare.ts @@ -0,0 +1,35 @@ +import type { Api, Model } from "../types"; + +/** Workers AI direct endpoint. */ +export const CLOUDFLARE_WORKERS_AI_BASE_URL = + "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1"; + +/** AI Gateway Unified API. https://developers.cloudflare.com/ai-gateway/usage/unified-api/ */ +export const CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL = + "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/compat"; + +/** AI Gateway → OpenAI passthrough. Used until /compat supports /v1/responses. */ +export const CLOUDFLARE_AI_GATEWAY_OPENAI_BASE_URL = + "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai"; + +/** AI Gateway → Anthropic passthrough. */ +export const CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL = + "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic"; + +export function isCloudflareProvider(provider: string): boolean { + return provider === "cloudflare-workers-ai" || provider === "cloudflare-ai-gateway"; +} + +/** Substitute `{VAR}` placeholders in a Cloudflare baseUrl from process.env. */ +export function resolveCloudflareBaseUrl(model: Model<Api>): string { + const url = model.baseUrl; + if (!url.includes("{")) return url; + const baseUrl = url.replace(/\{([A-Z_][A-Z0-9_]*)\}/g, (_match, name: string) => { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is required for provider ${model.provider} but is not set.`); + } + return value; + }); + return baseUrl; +} diff --git a/emain/ai/providers/github-copilot-headers.ts b/emain/ai/providers/github-copilot-headers.ts new file mode 100644 index 000000000..03e447985 --- /dev/null +++ b/emain/ai/providers/github-copilot-headers.ts @@ -0,0 +1,37 @@ +import type { Message } from "../types"; + +// Copilot expects X-Initiator to indicate whether the request is user-initiated +// or agent-initiated (e.g. follow-up after assistant/tool messages). +export function inferCopilotInitiator(messages: Message[]): "user" | "agent" { + const last = messages[messages.length - 1]; + return last && last.role !== "user" ? "agent" : "user"; +} + +// Copilot requires Copilot-Vision-Request header when sending images +export function hasCopilotVisionInput(messages: Message[]): boolean { + return messages.some((msg) => { + if (msg.role === "user" && Array.isArray(msg.content)) { + return msg.content.some((c) => c.type === "image"); + } + if (msg.role === "toolResult" && Array.isArray(msg.content)) { + return msg.content.some((c) => c.type === "image"); + } + return false; + }); +} + +export function buildCopilotDynamicHeaders(params: { + messages: Message[]; + hasImages: boolean; +}): Record<string, string> { + const headers: Record<string, string> = { + "X-Initiator": inferCopilotInitiator(params.messages), + "Openai-Intent": "conversation-edits", + }; + + if (params.hasImages) { + headers["Copilot-Vision-Request"] = "true"; + } + + return headers; +} diff --git a/emain/ai/providers/google-shared.ts b/emain/ai/providers/google-shared.ts new file mode 100644 index 000000000..35b3353ef --- /dev/null +++ b/emain/ai/providers/google-shared.ts @@ -0,0 +1,350 @@ +/** + * Shared utilities for Google Generative AI and Google Vertex providers. + */ + +import { type Content, FinishReason, FunctionCallingConfigMode, type Part } from "@google/genai"; +import type { Context, ImageContent, Model, StopReason, TextContent, Tool } from "../types"; +import { sanitizeSurrogates } from "../utils/sanitize-unicode"; +import { transformMessages } from "./transform-messages"; + +type GoogleApiType = "google-generative-ai" | "google-vertex"; + +/** + * Thinking level for Gemini 3 models. + * Mirrors Google's ThinkingLevel enum values. + */ +export type GoogleThinkingLevel = "THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LOW" | "MEDIUM" | "HIGH"; + +/** + * Determines whether a streamed Gemini `Part` should be treated as "thinking". + * + * Protocol note (Gemini / Vertex AI thought signatures): + * - `thought: true` is the definitive marker for thinking content (thought summaries). + * - `thoughtSignature` is an encrypted representation of the model's internal thought process + * used to preserve reasoning context across multi-turn interactions. + * - `thoughtSignature` can appear on ANY part type (text, functionCall, etc.) - it does NOT + * indicate the part itself is thinking content. + * - For non-functionCall responses, the signature appears on the last part for context replay. + * - When persisting/replaying model outputs, signature-bearing parts must be preserved as-is; + * do not merge/move signatures across parts. + * + * See: https://ai.google.dev/gemini-api/docs/thought-signatures + */ +export function isThinkingPart(part: Pick<Part, "thought" | "thoughtSignature">): boolean { + return part.thought === true; +} + +/** + * Retain thought signatures during streaming. + * + * Some backends only send `thoughtSignature` on the first delta for a given part/block; later deltas may omit it. + * This helper preserves the last non-empty signature for the current block. + * + * Note: this does NOT merge or move signatures across distinct response parts. It only prevents + * a signature from being overwritten with `undefined` within the same streamed block. + */ +export function retainThoughtSignature(existing: string | undefined, incoming: string | undefined): string | undefined { + if (typeof incoming === "string" && incoming.length > 0) return incoming; + return existing; +} + +// Thought signatures must be base64 for Google APIs (TYPE_BYTES). +const base64SignaturePattern = /^[A-Za-z0-9+/]+={0,2}$/; + +function isValidThoughtSignature(signature: string | undefined): boolean { + if (!signature) return false; + if (signature.length % 4 !== 0) return false; + return base64SignaturePattern.test(signature); +} + +/** + * Only keep signatures from the same provider/model and with valid base64. + */ +function resolveThoughtSignature(isSameProviderAndModel: boolean, signature: string | undefined): string | undefined { + return isSameProviderAndModel && isValidThoughtSignature(signature) ? signature : undefined; +} + +/** + * Models via Google APIs that require explicit tool call IDs in function calls/responses. + */ +export function requiresToolCallId(modelId: string): boolean { + return modelId.startsWith("claude-") || modelId.startsWith("gpt-oss-"); +} + +function getGeminiMajorVersion(modelId: string): number | undefined { + const match = modelId.toLowerCase().match(/^gemini(?:-live)?-(\d+)/); + if (!match) return undefined; + return Number.parseInt(match[1], 10); +} + +function supportsMultimodalFunctionResponse(modelId: string): boolean { + const geminiMajorVersion = getGeminiMajorVersion(modelId); + if (geminiMajorVersion !== undefined) { + return geminiMajorVersion >= 3; + } + return true; +} + +/** + * Convert internal messages to Gemini Content[] format. + */ +export function convertMessages<T extends GoogleApiType>(model: Model<T>, context: Context): Content[] { + const contents: Content[] = []; + const normalizeToolCallId = (id: string): string => { + if (!requiresToolCallId(model.id)) return id; + return id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64); + }; + + const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId); + + for (const msg of transformedMessages) { + if (msg.role === "user") { + if (typeof msg.content === "string") { + contents.push({ + role: "user", + parts: [{ text: sanitizeSurrogates(msg.content) }], + }); + } else { + const parts: Part[] = msg.content.map((item) => { + if (item.type === "text") { + return { text: sanitizeSurrogates(item.text) }; + } else { + return { + inlineData: { + mimeType: item.mimeType, + data: item.data, + }, + }; + } + }); + if (parts.length === 0) continue; + contents.push({ + role: "user", + parts, + }); + } + } else if (msg.role === "assistant") { + const parts: Part[] = []; + // Check if message is from same provider and model - only then keep thinking blocks + const isSameProviderAndModel = msg.provider === model.provider && msg.model === model.id; + + for (const block of msg.content) { + if (block.type === "text") { + // Skip empty text blocks + if (!block.text || block.text.trim() === "") continue; + const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.textSignature); + parts.push({ + text: sanitizeSurrogates(block.text), + ...(thoughtSignature && { thoughtSignature }), + }); + } else if (block.type === "thinking") { + // Skip empty thinking blocks + if (!block.thinking || block.thinking.trim() === "") continue; + // Only keep as thinking block if same provider AND same model + // Otherwise convert to plain text (no tags to avoid model mimicking them) + if (isSameProviderAndModel) { + const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thinkingSignature); + parts.push({ + thought: true, + text: sanitizeSurrogates(block.thinking), + ...(thoughtSignature && { thoughtSignature }), + }); + } else { + parts.push({ + text: sanitizeSurrogates(block.thinking), + }); + } + } else if (block.type === "toolCall") { + const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thoughtSignature); + const part: Part = { + functionCall: { + name: block.name, + args: block.arguments ?? {}, + ...(requiresToolCallId(model.id) ? { id: block.id } : {}), + }, + ...(thoughtSignature && { thoughtSignature }), + }; + parts.push(part); + } + } + + if (parts.length === 0) continue; + contents.push({ + role: "model", + parts, + }); + } else if (msg.role === "toolResult") { + // Extract text and image content + const textContent = msg.content.filter((c): c is TextContent => c.type === "text"); + const textResult = textContent.map((c) => c.text).join("\n"); + const imageContent = model.input.includes("image") + ? msg.content.filter((c): c is ImageContent => c.type === "image") + : []; + + const hasText = textResult.length > 0; + const hasImages = imageContent.length > 0; + + // Gemini 3+ models support multimodal function responses with images nested inside + // functionResponse.parts. Claude and other non-Gemini models behind Cloud Code Assist / + // Gemini < 3 still needs a separate user image turn. + const modelSupportsMultimodalFunctionResponse = supportsMultimodalFunctionResponse(model.id); + + // Use "output" key for success, "error" key for errors as per SDK documentation + const responseValue = hasText ? sanitizeSurrogates(textResult) : hasImages ? "(see attached image)" : ""; + + const imageParts: Part[] = imageContent.map((imageBlock) => ({ + inlineData: { + mimeType: imageBlock.mimeType, + data: imageBlock.data, + }, + })); + + const includeId = requiresToolCallId(model.id); + const functionResponsePart: Part = { + functionResponse: { + name: msg.toolName, + response: msg.isError ? { error: responseValue } : { output: responseValue }, + ...(hasImages && modelSupportsMultimodalFunctionResponse && { parts: imageParts }), + ...(includeId ? { id: msg.toolCallId } : {}), + }, + }; + + // Cloud Code Assist API requires all function responses to be in a single user turn. + // Check if the last content is already a user turn with function responses and merge. + const lastContent = contents[contents.length - 1]; + if (lastContent?.role === "user" && lastContent.parts?.some((p) => p.functionResponse)) { + lastContent.parts.push(functionResponsePart); + } else { + contents.push({ + role: "user", + parts: [functionResponsePart], + }); + } + + // For Gemini < 3, add images in a separate user message + if (hasImages && !modelSupportsMultimodalFunctionResponse) { + contents.push({ + role: "user", + parts: [{ text: "Tool result image:" }, ...imageParts], + }); + } + } + } + + return contents; +} + +const JSON_SCHEMA_META_DECLARATIONS = new Set([ + "$schema", + "$id", + "$anchor", + "$dynamicAnchor", + "$vocabulary", + "$comment", + "$defs", + "definitions", // pre-draft-2019-09 equivalent of $defs +]); + +/** + * Strip meta-declarations from a schema obj + */ +function sanitizeForOpenApi(schema: unknown): unknown { + if (typeof schema !== "object" || schema === null || Array.isArray(schema)) { + return schema; + } + + const result: Record<string, unknown> = {}; + for (const [key, value] of Object.entries(schema)) { + if (JSON_SCHEMA_META_DECLARATIONS.has(key)) continue; + result[key] = sanitizeForOpenApi(value); + } + return result; +} + +/** + * Convert tools to Gemini function declarations format. + * + * By default uses `parametersJsonSchema` which supports full JSON Schema (including + * anyOf, oneOf, const, etc.). Set `useParameters` to true to use the legacy `parameters` + * field instead (OpenAPI 3.03 Schema). This is needed for Cloud Code Assist with Claude + * models, where the API translates `parameters` into Anthropic's `input_schema`. + */ +export function convertTools( + tools: Tool[], + useParameters = false, +): { functionDeclarations: Record<string, unknown>[] }[] | undefined { + if (tools.length === 0) return undefined; + return [ + { + functionDeclarations: tools.map((tool) => ({ + name: tool.name, + description: tool.description, + ...(useParameters + ? { parameters: sanitizeForOpenApi(tool.parameters as unknown) } + : { parametersJsonSchema: tool.parameters }), + })), + }, + ]; +} + +/** + * Map tool choice string to Gemini FunctionCallingConfigMode. + */ +export function mapToolChoice(choice: string): FunctionCallingConfigMode { + switch (choice) { + case "auto": + return FunctionCallingConfigMode.AUTO; + case "none": + return FunctionCallingConfigMode.NONE; + case "any": + return FunctionCallingConfigMode.ANY; + default: + return FunctionCallingConfigMode.AUTO; + } +} + +/** + * Map Gemini FinishReason to our StopReason. + */ +export function mapStopReason(reason: FinishReason): StopReason { + switch (reason) { + case FinishReason.STOP: + return "stop"; + case FinishReason.MAX_TOKENS: + return "length"; + case FinishReason.BLOCKLIST: + case FinishReason.PROHIBITED_CONTENT: + case FinishReason.SPII: + case FinishReason.SAFETY: + case FinishReason.IMAGE_SAFETY: + case FinishReason.IMAGE_PROHIBITED_CONTENT: + case FinishReason.IMAGE_RECITATION: + case FinishReason.IMAGE_OTHER: + case FinishReason.RECITATION: + case FinishReason.FINISH_REASON_UNSPECIFIED: + case FinishReason.OTHER: + case FinishReason.LANGUAGE: + case FinishReason.MALFORMED_FUNCTION_CALL: + case FinishReason.UNEXPECTED_TOOL_CALL: + case FinishReason.NO_IMAGE: + return "error"; + default: { + const _exhaustive: never = reason; + throw new Error(`Unhandled stop reason: ${_exhaustive}`); + } + } +} + +/** + * Map string finish reason to our StopReason (for raw API responses). + */ +export function mapStopReasonString(reason: string): StopReason { + switch (reason) { + case "STOP": + return "stop"; + case "MAX_TOKENS": + return "length"; + default: + return "error"; + } +} diff --git a/emain/ai/providers/google.ts b/emain/ai/providers/google.ts new file mode 100644 index 000000000..4d74e0c62 --- /dev/null +++ b/emain/ai/providers/google.ts @@ -0,0 +1,501 @@ +import { + type GenerateContentConfig, + type GenerateContentParameters, + GoogleGenAI, + type ThinkingConfig, +} from "@google/genai"; +import { getEnvApiKey } from "../env-api-keys"; +import { calculateCost, clampThinkingLevel } from "../models"; +import type { + Api, + AssistantMessage, + Context, + Model, + SimpleStreamOptions, + StreamFunction, + StreamOptions, + TextContent, + ThinkingBudgets, + ThinkingContent, + ThinkingLevel, + ToolCall, +} from "../types"; +import { AssistantMessageEventStream } from "../utils/event-stream"; +import { sanitizeSurrogates } from "../utils/sanitize-unicode"; +import type { GoogleThinkingLevel } from "./google-shared"; +import { + convertMessages, + convertTools, + isThinkingPart, + mapStopReason, + mapToolChoice, + retainThoughtSignature, +} from "./google-shared"; +import { buildBaseOptions } from "./simple-options"; + +export interface GoogleOptions extends StreamOptions { + toolChoice?: "auto" | "none" | "any"; + thinking?: { + enabled: boolean; + budgetTokens?: number; // -1 for dynamic, 0 to disable + level?: GoogleThinkingLevel; + }; +} + +// Counter for generating unique tool call IDs +let toolCallCounter = 0; + +export const streamGoogle: StreamFunction<"google-generative-ai", GoogleOptions> = ( + model: Model<"google-generative-ai">, + context: Context, + options?: GoogleOptions, +): AssistantMessageEventStream => { + const stream = new AssistantMessageEventStream(); + + (async () => { + const output: AssistantMessage = { + role: "assistant", + content: [], + api: "google-generative-ai" as Api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + + try { + const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; + const client = createClient(model, apiKey, options?.headers); + let params = buildParams(model, context, options); + const nextParams = await options?.onPayload?.(params, model); + if (nextParams !== undefined) { + params = nextParams as GenerateContentParameters; + } + const googleStream = await client.models.generateContentStream(params); + + stream.push({ type: "start", partial: output }); + let currentBlock: TextContent | ThinkingContent | null = null; + const blocks = output.content; + const blockIndex = () => blocks.length - 1; + for await (const chunk of googleStream) { + // @google/genai documents GenerateContentResponse.responseId as an output-only field + // used to identify each response. Keep the first non-empty one from the stream. + output.responseId ||= chunk.responseId; + const candidate = chunk.candidates?.[0]; + if (candidate?.content?.parts) { + for (const part of candidate.content.parts) { + if (part.text !== undefined) { + const isThinking = isThinkingPart(part); + if ( + !currentBlock || + (isThinking && currentBlock.type !== "thinking") || + (!isThinking && currentBlock.type !== "text") + ) { + if (currentBlock) { + if (currentBlock.type === "text") { + stream.push({ + type: "text_end", + contentIndex: blocks.length - 1, + content: currentBlock.text, + partial: output, + }); + } else { + stream.push({ + type: "thinking_end", + contentIndex: blockIndex(), + content: currentBlock.thinking, + partial: output, + }); + } + } + if (isThinking) { + currentBlock = { type: "thinking", thinking: "", thinkingSignature: undefined }; + output.content.push(currentBlock); + stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output }); + } else { + currentBlock = { type: "text", text: "" }; + output.content.push(currentBlock); + stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output }); + } + } + if (currentBlock.type === "thinking") { + currentBlock.thinking += part.text; + currentBlock.thinkingSignature = retainThoughtSignature( + currentBlock.thinkingSignature, + part.thoughtSignature, + ); + stream.push({ + type: "thinking_delta", + contentIndex: blockIndex(), + delta: part.text, + partial: output, + }); + } else { + currentBlock.text += part.text; + currentBlock.textSignature = retainThoughtSignature( + currentBlock.textSignature, + part.thoughtSignature, + ); + stream.push({ + type: "text_delta", + contentIndex: blockIndex(), + delta: part.text, + partial: output, + }); + } + } + + if (part.functionCall) { + if (currentBlock) { + if (currentBlock.type === "text") { + stream.push({ + type: "text_end", + contentIndex: blockIndex(), + content: currentBlock.text, + partial: output, + }); + } else { + stream.push({ + type: "thinking_end", + contentIndex: blockIndex(), + content: currentBlock.thinking, + partial: output, + }); + } + currentBlock = null; + } + + // Generate unique ID if not provided or if it's a duplicate + const providedId = part.functionCall.id; + const needsNewId = + !providedId || output.content.some((b) => b.type === "toolCall" && b.id === providedId); + const toolCallId = needsNewId + ? `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}` + : providedId; + + const toolCall: ToolCall = { + type: "toolCall", + id: toolCallId, + name: part.functionCall.name || "", + arguments: (part.functionCall.args as Record<string, any>) ?? {}, + ...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }), + }; + + output.content.push(toolCall); + stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output }); + stream.push({ + type: "toolcall_delta", + contentIndex: blockIndex(), + delta: JSON.stringify(toolCall.arguments), + partial: output, + }); + stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output }); + } + } + } + + if (candidate?.finishReason) { + output.stopReason = mapStopReason(candidate.finishReason); + if (output.content.some((b) => b.type === "toolCall")) { + output.stopReason = "toolUse"; + } + } + + if (chunk.usageMetadata) { + output.usage = { + input: + (chunk.usageMetadata.promptTokenCount || 0) - (chunk.usageMetadata.cachedContentTokenCount || 0), + output: + (chunk.usageMetadata.candidatesTokenCount || 0) + (chunk.usageMetadata.thoughtsTokenCount || 0), + cacheRead: chunk.usageMetadata.cachedContentTokenCount || 0, + cacheWrite: 0, + totalTokens: chunk.usageMetadata.totalTokenCount || 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }; + calculateCost(model, output.usage); + } + } + + if (currentBlock) { + if (currentBlock.type === "text") { + stream.push({ + type: "text_end", + contentIndex: blockIndex(), + content: currentBlock.text, + partial: output, + }); + } else { + stream.push({ + type: "thinking_end", + contentIndex: blockIndex(), + content: currentBlock.thinking, + partial: output, + }); + } + } + + if (options?.signal?.aborted) { + throw new Error("Request was aborted"); + } + + if (output.stopReason === "aborted" || output.stopReason === "error") { + throw new Error("An unknown error occurred"); + } + + stream.push({ type: "done", reason: output.stopReason, message: output }); + stream.end(); + } catch (error) { + // Remove internal index property used during streaming + for (const block of output.content) { + if ("index" in block) { + delete (block as { index?: number }).index; + } + } + output.stopReason = options?.signal?.aborted ? "aborted" : "error"; + output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + stream.push({ type: "error", reason: output.stopReason, error: output }); + stream.end(); + } + })(); + + return stream; +}; + +export const streamSimpleGoogle: StreamFunction<"google-generative-ai", SimpleStreamOptions> = ( + model: Model<"google-generative-ai">, + context: Context, + options?: SimpleStreamOptions, +): AssistantMessageEventStream => { + const apiKey = options?.apiKey || getEnvApiKey(model.provider); + if (!apiKey) { + throw new Error(`No API key for provider: ${model.provider}`); + } + + const base = buildBaseOptions(model, options, apiKey); + if (!options?.reasoning) { + return streamGoogle(model, context, { ...base, thinking: { enabled: false } } satisfies GoogleOptions); + } + + const clampedReasoning = clampThinkingLevel(model, options.reasoning); + const effort = (clampedReasoning === "off" ? "high" : clampedReasoning) as ClampedThinkingLevel; + const googleModel = model as Model<"google-generative-ai">; + + if (isGemini3ProModel(googleModel) || isGemini3FlashModel(googleModel) || isGemma4Model(googleModel)) { + return streamGoogle(model, context, { + ...base, + thinking: { + enabled: true, + level: getThinkingLevel(effort, googleModel), + }, + } satisfies GoogleOptions); + } + + return streamGoogle(model, context, { + ...base, + thinking: { + enabled: true, + budgetTokens: getGoogleBudget(googleModel, effort, options.thinkingBudgets), + }, + } satisfies GoogleOptions); +}; + +function createClient( + model: Model<"google-generative-ai">, + apiKey?: string, + optionsHeaders?: Record<string, string>, +): GoogleGenAI { + const httpOptions: { baseUrl?: string; apiVersion?: string; headers?: Record<string, string> } = {}; + if (model.baseUrl) { + httpOptions.baseUrl = model.baseUrl; + httpOptions.apiVersion = ""; // baseUrl already includes version path, don't append + } + if (model.headers || optionsHeaders) { + httpOptions.headers = { ...model.headers, ...optionsHeaders }; + } + + return new GoogleGenAI({ + apiKey, + httpOptions: Object.keys(httpOptions).length > 0 ? httpOptions : undefined, + }); +} + +function buildParams( + model: Model<"google-generative-ai">, + context: Context, + options: GoogleOptions = {}, +): GenerateContentParameters { + const contents = convertMessages(model, context); + + const generationConfig: GenerateContentConfig = {}; + if (options.temperature !== undefined) { + generationConfig.temperature = options.temperature; + } + if (options.maxTokens !== undefined) { + generationConfig.maxOutputTokens = options.maxTokens; + } + + const config: GenerateContentConfig = { + ...(Object.keys(generationConfig).length > 0 && generationConfig), + ...(context.systemPrompt && { systemInstruction: sanitizeSurrogates(context.systemPrompt) }), + ...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools) }), + }; + + if (context.tools && context.tools.length > 0 && options.toolChoice) { + config.toolConfig = { + functionCallingConfig: { + mode: mapToolChoice(options.toolChoice), + }, + }; + } else { + config.toolConfig = undefined; + } + + if (options.thinking?.enabled && model.reasoning) { + const thinkingConfig: ThinkingConfig = { includeThoughts: true }; + if (options.thinking.level !== undefined) { + // Cast to any since our GoogleThinkingLevel mirrors Google's ThinkingLevel enum values + thinkingConfig.thinkingLevel = options.thinking.level as any; + } else if (options.thinking.budgetTokens !== undefined) { + thinkingConfig.thinkingBudget = options.thinking.budgetTokens; + } + config.thinkingConfig = thinkingConfig; + } else if (model.reasoning && options.thinking && !options.thinking.enabled) { + config.thinkingConfig = getDisabledThinkingConfig(model); + } + + if (options.signal) { + if (options.signal.aborted) { + throw new Error("Request aborted"); + } + config.abortSignal = options.signal; + } + + const params: GenerateContentParameters = { + model: model.id, + contents, + config, + }; + + return params; +} + +type ClampedThinkingLevel = Exclude<ThinkingLevel, "xhigh">; + +function isGemma4Model(model: Model<"google-generative-ai">): boolean { + return /gemma-?4/.test(model.id.toLowerCase()); +} + +function isGemini3ProModel(model: Model<"google-generative-ai">): boolean { + return /gemini-3(?:\.\d+)?-pro/.test(model.id.toLowerCase()); +} + +function isGemini3FlashModel(model: Model<"google-generative-ai">): boolean { + return /gemini-3(?:\.\d+)?-flash/.test(model.id.toLowerCase()); +} + +function getDisabledThinkingConfig(model: Model<"google-generative-ai">): ThinkingConfig { + // Google docs: Gemini 3.1 Pro cannot disable thinking, and Gemini 3 Flash / Flash-Lite + // do not support full thinking-off either. For Gemini 3 models, use the lowest supported + // thinkingLevel without includeThoughts so hidden thinking remains invisible to pi. + if (isGemini3ProModel(model)) { + return { thinkingLevel: "LOW" as any }; + } + if (isGemini3FlashModel(model)) { + return { thinkingLevel: "MINIMAL" as any }; + } + if (isGemma4Model(model)) { + return { thinkingLevel: "MINIMAL" as any }; + } + + // Gemini 2.x supports disabling via thinkingBudget = 0. + return { thinkingBudget: 0 }; +} + +function getThinkingLevel(effort: ClampedThinkingLevel, model: Model<"google-generative-ai">): GoogleThinkingLevel { + if (isGemini3ProModel(model)) { + switch (effort) { + case "minimal": + case "low": + return "LOW"; + case "medium": + case "high": + return "HIGH"; + } + } + if (isGemma4Model(model)) { + switch (effort) { + case "minimal": + case "low": + return "MINIMAL"; + case "medium": + case "high": + return "HIGH"; + } + } + switch (effort) { + case "minimal": + return "MINIMAL"; + case "low": + return "LOW"; + case "medium": + return "MEDIUM"; + case "high": + return "HIGH"; + } +} + +function getGoogleBudget( + model: Model<"google-generative-ai">, + effort: ClampedThinkingLevel, + customBudgets?: ThinkingBudgets, +): number { + if (customBudgets?.[effort] !== undefined) { + return customBudgets[effort]!; + } + + if (model.id.includes("2.5-pro")) { + const budgets: Record<ClampedThinkingLevel, number> = { + minimal: 128, + low: 2048, + medium: 8192, + high: 32768, + }; + return budgets[effort]; + } + + if (model.id.includes("2.5-flash-lite")) { + const budgets: Record<ClampedThinkingLevel, number> = { + minimal: 512, + low: 2048, + medium: 8192, + high: 24576, + }; + return budgets[effort]; + } + + if (model.id.includes("2.5-flash")) { + const budgets: Record<ClampedThinkingLevel, number> = { + minimal: 128, + low: 2048, + medium: 8192, + high: 24576, + }; + return budgets[effort]; + } + + return -1; +} diff --git a/emain/ai/providers/openai-completions.ts b/emain/ai/providers/openai-completions.ts new file mode 100644 index 000000000..7cbe7df64 --- /dev/null +++ b/emain/ai/providers/openai-completions.ts @@ -0,0 +1,1161 @@ +import OpenAI from "openai"; +import type { + ChatCompletionAssistantMessageParam, + ChatCompletionChunk, + ChatCompletionContentPart, + ChatCompletionContentPartImage, + ChatCompletionContentPartText, + ChatCompletionDeveloperMessageParam, + ChatCompletionMessageParam, + ChatCompletionSystemMessageParam, + ChatCompletionToolMessageParam, +} from "openai/resources/chat/completions.js"; +import { getEnvApiKey } from "../env-api-keys"; +import { calculateCost, clampThinkingLevel } from "../models"; +import type { + AssistantMessage, + CacheRetention, + Context, + ImageContent, + Message, + Model, + OpenAICompletionsCompat, + SimpleStreamOptions, + StopReason, + StreamFunction, + StreamOptions, + TextContent, + ThinkingContent, + Tool, + ToolCall, + ToolResultMessage, +} from "../types"; +import { AssistantMessageEventStream } from "../utils/event-stream"; +import { headersToRecord } from "../utils/headers"; +import { parseStreamingJson } from "../utils/json-parse"; +import { sanitizeSurrogates } from "../utils/sanitize-unicode"; +import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare"; +import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers"; +import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache"; +import { buildBaseOptions } from "./simple-options"; +import { transformMessages } from "./transform-messages"; + +/** + * Check if conversation messages contain tool calls or tool results. + * This is needed because Anthropic (via proxy) requires the tools param + * to be present when messages include tool_calls or tool role messages. + */ +function hasToolHistory(messages: Message[]): boolean { + for (const msg of messages) { + if (msg.role === "toolResult") { + return true; + } + if (msg.role === "assistant") { + if (msg.content.some((block) => block.type === "toolCall")) { + return true; + } + } + } + return false; +} + +function isTextContentBlock(block: { type: string }): block is TextContent { + return block.type === "text"; +} + +function isThinkingContentBlock(block: { type: string }): block is ThinkingContent { + return block.type === "thinking"; +} + +function isToolCallBlock(block: { type: string }): block is ToolCall { + return block.type === "toolCall"; +} + +function isImageContentBlock(block: { type: string }): block is ImageContent { + return block.type === "image"; +} + +export interface OpenAICompletionsOptions extends StreamOptions { + toolChoice?: "auto" | "none" | "required" | { type: "function"; function: { name: string } }; + reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh"; +} + +interface OpenAICompatCacheControl { + type: "ephemeral"; + ttl?: string; +} + +type ResolvedOpenAICompletionsCompat = Omit<Required<OpenAICompletionsCompat>, "cacheControlFormat"> & { + cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"]; +}; + +type ChatCompletionInstructionMessageParam = ChatCompletionDeveloperMessageParam | ChatCompletionSystemMessageParam; + +type ChatCompletionTextPartWithCacheControl = ChatCompletionContentPartText & { + cache_control?: OpenAICompatCacheControl; +}; + +type ChatCompletionToolWithCacheControl = OpenAI.Chat.Completions.ChatCompletionTool & { + cache_control?: OpenAICompatCacheControl; +}; + +function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention { + if (cacheRetention) { + return cacheRetention; + } + if (typeof process !== "undefined" && process.env.PI_CACHE_RETENTION === "long") { + return "long"; + } + return "short"; +} + +export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenAICompletionsOptions> = ( + model: Model<"openai-completions">, + context: Context, + options?: OpenAICompletionsOptions, +): AssistantMessageEventStream => { + const stream = new AssistantMessageEventStream(); + + (async () => { + const output: AssistantMessage = { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + + try { + const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; + const compat = getCompat(model); + const cacheRetention = resolveCacheRetention(options?.cacheRetention); + const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId; + const client = createClient(model, context, apiKey, options?.headers, cacheSessionId, compat); + let params = buildParams(model, context, options, compat, cacheRetention); + const nextParams = await options?.onPayload?.(params, model); + if (nextParams !== undefined) { + params = nextParams as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming; + } + const requestOptions = { + ...(options?.signal ? { signal: options.signal } : {}), + ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), + ...(options?.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), + }; + const { data: openaiStream, response } = await client.chat.completions + .create(params, requestOptions) + .withResponse(); + await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model); + stream.push({ type: "start", partial: output }); + + interface StreamingToolCallBlock extends ToolCall { + partialArgs?: string; + streamIndex?: number; + } + type StreamingBlock = TextContent | ThinkingContent | StreamingToolCallBlock; + type StreamingToolCallDelta = NonNullable<ChatCompletionChunk.Choice.Delta["tool_calls"]>[number]; + + let textBlock: TextContent | null = null; + let thinkingBlock: ThinkingContent | null = null; + let hasFinishReason = false; + const toolCallBlocksByIndex = new Map<number, StreamingToolCallBlock>(); + const toolCallBlocksById = new Map<string, StreamingToolCallBlock>(); + const blocks = output.content as StreamingBlock[]; + const getContentIndex = (block: StreamingBlock) => blocks.indexOf(block); + const finishBlock = (block: StreamingBlock) => { + const contentIndex = getContentIndex(block); + if (contentIndex === -1) { + return; + } + if (block.type === "text") { + stream.push({ + type: "text_end", + contentIndex, + content: block.text, + partial: output, + }); + } else if (block.type === "thinking") { + stream.push({ + type: "thinking_end", + contentIndex, + content: block.thinking, + partial: output, + }); + } else if (block.type === "toolCall") { + block.arguments = parseStreamingJson(block.partialArgs); + // Finalize in-place and strip the scratch buffers so replay only + // carries parsed arguments. + delete block.partialArgs; + delete block.streamIndex; + stream.push({ + type: "toolcall_end", + contentIndex, + toolCall: block, + partial: output, + }); + } + }; + const ensureTextBlock = () => { + if (!textBlock) { + textBlock = { type: "text", text: "" }; + blocks.push(textBlock); + stream.push({ type: "text_start", contentIndex: getContentIndex(textBlock), partial: output }); + } + return textBlock; + }; + const ensureThinkingBlock = (thinkingSignature: string) => { + if (!thinkingBlock) { + thinkingBlock = { + type: "thinking", + thinking: "", + thinkingSignature, + }; + blocks.push(thinkingBlock); + stream.push({ type: "thinking_start", contentIndex: getContentIndex(thinkingBlock), partial: output }); + } + return thinkingBlock; + }; + const ensureToolCallBlock = (toolCall: StreamingToolCallDelta) => { + const streamIndex = typeof toolCall.index === "number" ? toolCall.index : undefined; + let block = streamIndex !== undefined ? toolCallBlocksByIndex.get(streamIndex) : undefined; + if (!block && toolCall.id) { + block = toolCallBlocksById.get(toolCall.id); + } + if (!block) { + block = { + type: "toolCall", + id: toolCall.id || "", + name: toolCall.function?.name || "", + arguments: {}, + partialArgs: "", + streamIndex, + }; + if (streamIndex !== undefined) { + toolCallBlocksByIndex.set(streamIndex, block); + } + if (toolCall.id) { + toolCallBlocksById.set(toolCall.id, block); + } + blocks.push(block); + stream.push({ + type: "toolcall_start", + contentIndex: getContentIndex(block), + partial: output, + }); + } + if (streamIndex !== undefined && block.streamIndex === undefined) { + block.streamIndex = streamIndex; + toolCallBlocksByIndex.set(streamIndex, block); + } + if (toolCall.id) { + toolCallBlocksById.set(toolCall.id, block); + } + return block; + }; + + for await (const chunk of openaiStream) { + if (!chunk || typeof chunk !== "object") continue; + + // OpenAI documents ChatCompletionChunk.id as the unique chat completion identifier, + // and each chunk in a streamed completion carries the same id. + output.responseId ||= chunk.id; + if (typeof chunk.model === "string" && chunk.model.length > 0 && chunk.model !== model.id) { + output.responseModel ||= chunk.model; + } + if (chunk.usage) { + output.usage = parseChunkUsage(chunk.usage, model); + } + + const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined; + if (!choice) continue; + + // Fallback: some providers (e.g., Moonshot) return usage + // in choice.usage instead of the standard chunk.usage + if (!chunk.usage && (choice as any).usage) { + output.usage = parseChunkUsage((choice as any).usage, model); + } + + if (choice.finish_reason) { + const finishReasonResult = mapStopReason(choice.finish_reason); + output.stopReason = finishReasonResult.stopReason; + if (finishReasonResult.errorMessage) { + output.errorMessage = finishReasonResult.errorMessage; + } + hasFinishReason = true; + } + + if (choice.delta) { + if ( + choice.delta.content !== null && + choice.delta.content !== undefined && + choice.delta.content.length > 0 + ) { + const block = ensureTextBlock(); + block.text += choice.delta.content; + stream.push({ + type: "text_delta", + contentIndex: getContentIndex(block), + delta: choice.delta.content, + partial: output, + }); + } + + // Some endpoints return reasoning in reasoning_content (llama.cpp), + // or reasoning (other openai compatible endpoints) + // Use the first non-empty reasoning field to avoid duplication + // (e.g., chutes.ai returns both reasoning_content and reasoning with same content) + const reasoningFields = ["reasoning_content", "reasoning", "reasoning_text"]; + const deltaFields = choice.delta as Record<string, unknown>; + let foundReasoningField: string | null = null; + for (const field of reasoningFields) { + const value = deltaFields[field]; + if (typeof value === "string" && value.length > 0) { + foundReasoningField = field; + break; + } + } + + if (foundReasoningField) { + const delta = deltaFields[foundReasoningField]; + if (typeof delta === "string" && delta.length > 0) { + const thinkingSignature = + model.provider === "opencode-go" && foundReasoningField === "reasoning" + ? "reasoning_content" + : foundReasoningField; + const block = ensureThinkingBlock(thinkingSignature); + block.thinking += delta; + stream.push({ + type: "thinking_delta", + contentIndex: getContentIndex(block), + delta, + partial: output, + }); + } + } + + if (choice?.delta?.tool_calls) { + for (const toolCall of choice.delta.tool_calls) { + const block = ensureToolCallBlock(toolCall); + if (!block.id && toolCall.id) { + block.id = toolCall.id; + toolCallBlocksById.set(toolCall.id, block); + } + if (!block.name && toolCall.function?.name) { + block.name = toolCall.function.name; + } + + let delta = ""; + if (toolCall.function?.arguments) { + delta = toolCall.function.arguments; + block.partialArgs = (block.partialArgs ?? "") + toolCall.function.arguments; + block.arguments = parseStreamingJson(block.partialArgs); + } + stream.push({ + type: "toolcall_delta", + contentIndex: getContentIndex(block), + delta, + partial: output, + }); + } + } + + const reasoningDetails = (choice.delta as any).reasoning_details; + if (reasoningDetails && Array.isArray(reasoningDetails)) { + for (const detail of reasoningDetails) { + if (detail.type === "reasoning.encrypted" && detail.id && detail.data) { + const matchingToolCall = output.content.find( + (b) => b.type === "toolCall" && b.id === detail.id, + ) as ToolCall | undefined; + if (matchingToolCall) { + matchingToolCall.thoughtSignature = JSON.stringify(detail); + } + } + } + } + } + } + + for (const block of blocks) { + finishBlock(block); + } + if (options?.signal?.aborted) { + throw new Error("Request was aborted"); + } + + if (output.stopReason === "aborted") { + throw new Error("Request was aborted"); + } + if (output.stopReason === "error") { + throw new Error(output.errorMessage || "Provider returned an error stop reason"); + } + if (!hasFinishReason) { + throw new Error("Stream ended without finish_reason"); + } + + stream.push({ type: "done", reason: output.stopReason, message: output }); + stream.end(); + } catch (error) { + for (const block of output.content) { + delete (block as { index?: number }).index; + // Streaming scratch buffers are only used during parsing; never persist them. + delete (block as { partialArgs?: string }).partialArgs; + delete (block as { streamIndex?: number }).streamIndex; + } + output.stopReason = options?.signal?.aborted ? "aborted" : "error"; + output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + // Some providers via OpenRouter give additional information in this field. + const rawMetadata = (error as any)?.error?.metadata?.raw; + if (rawMetadata) output.errorMessage += `\n${rawMetadata}`; + stream.push({ type: "error", reason: output.stopReason, error: output }); + stream.end(); + } + })(); + + return stream; +}; + +export const streamSimpleOpenAICompletions: StreamFunction<"openai-completions", SimpleStreamOptions> = ( + model: Model<"openai-completions">, + context: Context, + options?: SimpleStreamOptions, +): AssistantMessageEventStream => { + const apiKey = options?.apiKey || getEnvApiKey(model.provider); + if (!apiKey) { + throw new Error(`No API key for provider: ${model.provider}`); + } + + const base = buildBaseOptions(model, options, apiKey); + const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined; + const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning; + const toolChoice = (options as OpenAICompletionsOptions | undefined)?.toolChoice; + + return streamOpenAICompletions(model, context, { + ...base, + reasoningEffort, + toolChoice, + } satisfies OpenAICompletionsOptions); +}; + +function createClient( + model: Model<"openai-completions">, + context: Context, + apiKey?: string, + optionsHeaders?: Record<string, string>, + sessionId?: string, + compat: ResolvedOpenAICompletionsCompat = getCompat(model), +) { + if (!apiKey) { + if (!process.env.OPENAI_API_KEY) { + throw new Error( + "OpenAI API key is required. Set OPENAI_API_KEY environment variable or pass it as an argument.", + ); + } + apiKey = process.env.OPENAI_API_KEY; + } + + const headers = { ...model.headers }; + if (model.provider === "github-copilot") { + const hasImages = hasCopilotVisionInput(context.messages); + const copilotHeaders = buildCopilotDynamicHeaders({ + messages: context.messages, + hasImages, + }); + Object.assign(headers, copilotHeaders); + } + + if (sessionId && compat.sendSessionAffinityHeaders) { + headers.session_id = sessionId; + headers["x-client-request-id"] = sessionId; + headers["x-session-affinity"] = sessionId; + } + + // Merge options headers last so they can override defaults + if (optionsHeaders) { + Object.assign(headers, optionsHeaders); + } + + const defaultHeaders = + model.provider === "cloudflare-ai-gateway" + ? { + ...headers, + Authorization: headers.Authorization ?? null, + "cf-aig-authorization": `Bearer ${apiKey}`, + } + : headers; + + return new OpenAI({ + apiKey, + baseURL: isCloudflareProvider(model.provider) ? resolveCloudflareBaseUrl(model) : model.baseUrl, + dangerouslyAllowBrowser: true, + defaultHeaders, + }); +} + +function buildParams( + model: Model<"openai-completions">, + context: Context, + options?: OpenAICompletionsOptions, + compat: ResolvedOpenAICompletionsCompat = getCompat(model), + cacheRetention: CacheRetention = resolveCacheRetention(options?.cacheRetention), +) { + const messages = convertMessages(model, context, compat); + const cacheControl = getCompatCacheControl(compat, cacheRetention); + + const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { + model: model.id, + messages, + stream: true, + prompt_cache_key: + (model.baseUrl.includes("api.openai.com") && cacheRetention !== "none") || + (cacheRetention === "long" && compat.supportsLongCacheRetention) + ? clampOpenAIPromptCacheKey(options?.sessionId) + : undefined, + prompt_cache_retention: cacheRetention === "long" && compat.supportsLongCacheRetention ? "24h" : undefined, + }; + + if (compat.supportsUsageInStreaming !== false) { + (params as any).stream_options = { include_usage: true }; + } + + if (compat.supportsStore) { + params.store = false; + } + + if (options?.maxTokens) { + if (compat.maxTokensField === "max_tokens") { + (params as any).max_tokens = options.maxTokens; + } else { + params.max_completion_tokens = options.maxTokens; + } + } + + if (options?.temperature !== undefined) { + params.temperature = options.temperature; + } + + if (context.tools && context.tools.length > 0) { + params.tools = convertTools(context.tools, compat); + if (compat.zaiToolStream) { + (params as any).tool_stream = true; + } + } else if (hasToolHistory(context.messages)) { + // Anthropic (via LiteLLM/proxy) requires tools param when conversation has tool_calls/tool_results + params.tools = []; + } + + if (cacheControl) { + applyAnthropicCacheControl(messages, params.tools, cacheControl); + } + + if (options?.toolChoice) { + params.tool_choice = options.toolChoice; + } + + if (compat.thinkingFormat === "zai" && model.reasoning) { + (params as any).enable_thinking = !!options?.reasoningEffort; + } else if (compat.thinkingFormat === "qwen" && model.reasoning) { + (params as any).enable_thinking = !!options?.reasoningEffort; + } else if (compat.thinkingFormat === "qwen-chat-template" && model.reasoning) { + (params as any).chat_template_kwargs = { + enable_thinking: !!options?.reasoningEffort, + preserve_thinking: true, + }; + } else if (compat.thinkingFormat === "deepseek" && model.reasoning) { + (params as any).thinking = { type: options?.reasoningEffort ? "enabled" : "disabled" }; + if (options?.reasoningEffort) { + (params as any).reasoning_effort = + model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort; + } + } else if (compat.thinkingFormat === "openrouter" && model.reasoning) { + // OpenRouter normalizes reasoning across providers via a nested reasoning object. + const openRouterParams = params as typeof params & { reasoning?: { effort?: string } }; + if (options?.reasoningEffort) { + openRouterParams.reasoning = { + effort: model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort, + }; + } else if (model.thinkingLevelMap?.off !== null) { + openRouterParams.reasoning = { effort: model.thinkingLevelMap?.off ?? "none" }; + } + } else if (compat.thinkingFormat === "together" && model.reasoning) { + const togetherParams = params as Omit<typeof params, "reasoning_effort"> & { + reasoning?: { enabled: boolean }; + reasoning_effort?: string; + }; + togetherParams.reasoning = { enabled: !!options?.reasoningEffort }; + if (options?.reasoningEffort && compat.supportsReasoningEffort) { + togetherParams.reasoning_effort = model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort; + } + } else if (options?.reasoningEffort && model.reasoning && compat.supportsReasoningEffort) { + // OpenAI-style reasoning_effort + (params as any).reasoning_effort = model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort; + } else if (!options?.reasoningEffort && model.reasoning && compat.supportsReasoningEffort) { + const offValue = model.thinkingLevelMap?.off; + if (typeof offValue === "string") { + (params as any).reasoning_effort = offValue; + } + } + + // OpenRouter provider routing preferences + if (model.baseUrl.includes("openrouter.ai") && model.compat?.openRouterRouting) { + (params as any).provider = model.compat.openRouterRouting; + } + + // Vercel AI Gateway provider routing preferences + if (model.baseUrl.includes("ai-gateway.vercel.sh") && model.compat?.vercelGatewayRouting) { + const routing = model.compat.vercelGatewayRouting; + if (routing.only || routing.order) { + const gatewayOptions: Record<string, string[]> = {}; + if (routing.only) gatewayOptions.only = routing.only; + if (routing.order) gatewayOptions.order = routing.order; + (params as any).providerOptions = { gateway: gatewayOptions }; + } + } + + return params; +} + +function getCompatCacheControl( + compat: ResolvedOpenAICompletionsCompat, + cacheRetention: CacheRetention, +): OpenAICompatCacheControl | undefined { + if (compat.cacheControlFormat !== "anthropic" || cacheRetention === "none") { + return undefined; + } + + const ttl = cacheRetention === "long" && compat.supportsLongCacheRetention ? "1h" : undefined; + return { type: "ephemeral", ...(ttl ? { ttl } : {}) }; +} + +function applyAnthropicCacheControl( + messages: ChatCompletionMessageParam[], + tools: OpenAI.Chat.Completions.ChatCompletionTool[] | undefined, + cacheControl: OpenAICompatCacheControl, +): void { + addCacheControlToSystemPrompt(messages, cacheControl); + addCacheControlToLastTool(tools, cacheControl); + addCacheControlToLastConversationMessage(messages, cacheControl); +} + +function addCacheControlToSystemPrompt( + messages: ChatCompletionMessageParam[], + cacheControl: OpenAICompatCacheControl, +): void { + for (const message of messages) { + if (message.role === "system" || message.role === "developer") { + addCacheControlToInstructionMessage(message, cacheControl); + return; + } + } +} + +function addCacheControlToLastConversationMessage( + messages: ChatCompletionMessageParam[], + cacheControl: OpenAICompatCacheControl, +): void { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (message.role === "user" || message.role === "assistant") { + if (addCacheControlToMessage(message, cacheControl)) { + return; + } + } + } +} + +function addCacheControlToLastTool( + tools: OpenAI.Chat.Completions.ChatCompletionTool[] | undefined, + cacheControl: OpenAICompatCacheControl, +): void { + if (!tools || tools.length === 0) { + return; + } + + const lastTool = tools[tools.length - 1] as ChatCompletionToolWithCacheControl; + lastTool.cache_control = cacheControl; +} + +function addCacheControlToInstructionMessage( + message: ChatCompletionInstructionMessageParam, + cacheControl: OpenAICompatCacheControl, +): boolean { + return addCacheControlToTextContent(message, cacheControl); +} + +function addCacheControlToMessage( + message: ChatCompletionMessageParam, + cacheControl: OpenAICompatCacheControl, +): boolean { + if (message.role === "user" || message.role === "assistant") { + return addCacheControlToTextContent(message, cacheControl); + } + return false; +} + +function addCacheControlToTextContent( + message: + | ChatCompletionInstructionMessageParam + | ChatCompletionAssistantMessageParam + | Extract<ChatCompletionMessageParam, { role: "user" }>, + cacheControl: OpenAICompatCacheControl, +): boolean { + const content = message.content; + if (typeof content === "string") { + if (content.length === 0) { + return false; + } + message.content = [ + { + type: "text", + text: content, + cache_control: cacheControl, + }, + ] as ChatCompletionTextPartWithCacheControl[]; + return true; + } + + if (!Array.isArray(content)) { + return false; + } + + for (let i = content.length - 1; i >= 0; i--) { + const part = content[i]; + if (part?.type === "text") { + const textPart = part as ChatCompletionTextPartWithCacheControl; + textPart.cache_control = cacheControl; + return true; + } + } + + return false; +} + +export function convertMessages( + model: Model<"openai-completions">, + context: Context, + compat: ResolvedOpenAICompletionsCompat, +): ChatCompletionMessageParam[] { + const params: ChatCompletionMessageParam[] = []; + + const normalizeToolCallId = (id: string): string => { + // Handle pipe-separated IDs from OpenAI Responses API + // Format: {call_id}|{id} where {id} can be 400+ chars with special chars (+, /, =) + // These come from providers like github-copilot, openai-codex, opencode + // Extract just the call_id part and normalize it + if (id.includes("|")) { + const [callId] = id.split("|"); + // Sanitize to allowed chars and truncate to 40 chars (OpenAI limit) + return callId.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40); + } + + if (model.provider === "openai") return id.length > 40 ? id.slice(0, 40) : id; + return id; + }; + + const transformedMessages = transformMessages(context.messages, model, (id) => normalizeToolCallId(id)); + + if (context.systemPrompt) { + const useDeveloperRole = model.reasoning && compat.supportsDeveloperRole; + const role = useDeveloperRole ? "developer" : "system"; + params.push({ role: role, content: sanitizeSurrogates(context.systemPrompt) }); + } + + let lastRole: string | null = null; + + for (let i = 0; i < transformedMessages.length; i++) { + const msg = transformedMessages[i]; + // Some providers don't allow user messages directly after tool results + // Insert a synthetic assistant message to bridge the gap + if (compat.requiresAssistantAfterToolResult && lastRole === "toolResult" && msg.role === "user") { + params.push({ + role: "assistant", + content: "I have processed the tool results.", + }); + } + + if (msg.role === "user") { + if (typeof msg.content === "string") { + params.push({ + role: "user", + content: sanitizeSurrogates(msg.content), + }); + } else { + const content: ChatCompletionContentPart[] = msg.content.map((item): ChatCompletionContentPart => { + if (item.type === "text") { + return { + type: "text", + text: sanitizeSurrogates(item.text), + } satisfies ChatCompletionContentPartText; + } else { + return { + type: "image_url", + image_url: { + url: `data:${item.mimeType};base64,${item.data}`, + }, + } satisfies ChatCompletionContentPartImage; + } + }); + if (content.length === 0) continue; + params.push({ + role: "user", + content, + }); + } + } else if (msg.role === "assistant") { + // Some providers don't accept null content, use empty string instead + const assistantMsg: ChatCompletionAssistantMessageParam = { + role: "assistant", + content: compat.requiresAssistantAfterToolResult ? "" : null, + }; + + const assistantTextParts = msg.content + .filter(isTextContentBlock) + .filter((block) => block.text.trim().length > 0) + .map( + (block) => + ({ + type: "text", + text: sanitizeSurrogates(block.text), + }) satisfies ChatCompletionContentPartText, + ); + const assistantText = assistantTextParts.map((part) => part.text).join(""); + + const nonEmptyThinkingBlocks = msg.content + .filter(isThinkingContentBlock) + .filter((block) => block.thinking.trim().length > 0); + if (nonEmptyThinkingBlocks.length > 0) { + if (compat.requiresThinkingAsText) { + // Convert thinking blocks to plain text (no tags to avoid model mimicking them) + const thinkingText = nonEmptyThinkingBlocks + .map((block) => sanitizeSurrogates(block.thinking)) + .join("\n\n"); + assistantMsg.content = [{ type: "text", text: thinkingText }, ...assistantTextParts]; + } else { + // Always send assistant content as a plain string (OpenAI Chat Completions + // API standard format). Sending as an array of {type:"text", text:"..."} + // objects is non-standard and causes some models (e.g. DeepSeek V3.2 via + // NVIDIA NIM) to mirror the content-block structure literally in their + // output, producing recursive nesting like [{'type':'text','text':'[{...}]'}]. + if (assistantText.length > 0) { + assistantMsg.content = assistantText; + } + + // Use the signature from the first thinking block if available (for llama.cpp server + gpt-oss) + let signature = nonEmptyThinkingBlocks[0].thinkingSignature; + if (model.provider === "opencode-go" && signature === "reasoning") { + signature = "reasoning_content"; + } + if (signature && signature.length > 0) { + (assistantMsg as any)[signature] = nonEmptyThinkingBlocks.map((block) => block.thinking).join("\n"); + } + } + } else if (assistantText.length > 0) { + // Always send assistant content as a plain string (OpenAI Chat Completions + // API standard format). Sending as an array of {type:"text", text:"..."} + // objects is non-standard and causes some models (e.g. DeepSeek V3.2 via + // NVIDIA NIM) to mirror the content-block structure literally in their + // output, producing recursive nesting like [{'type':'text','text':'[{...}]'}]. + assistantMsg.content = assistantText; + } + + const toolCalls = msg.content.filter(isToolCallBlock); + if (toolCalls.length > 0) { + assistantMsg.tool_calls = toolCalls.map((tc) => ({ + id: tc.id, + type: "function" as const, + function: { + name: tc.name, + arguments: JSON.stringify(tc.arguments), + }, + })); + const reasoningDetails = toolCalls + .filter((tc) => tc.thoughtSignature) + .map((tc) => { + try { + return JSON.parse(tc.thoughtSignature!); + } catch { + return null; + } + }) + .filter(Boolean); + if (reasoningDetails.length > 0) { + (assistantMsg as any).reasoning_details = reasoningDetails; + } + } + if ( + compat.requiresReasoningContentOnAssistantMessages && + model.reasoning && + (assistantMsg as { reasoning_content?: string }).reasoning_content === undefined + ) { + (assistantMsg as { reasoning_content?: string }).reasoning_content = ""; + } + // Skip assistant messages that have no content and no tool calls. + // Some providers require "either content or tool_calls, but not none". + // Other providers also don't accept empty assistant messages. + // This handles aborted assistant responses that got no content. + const content = assistantMsg.content; + const hasContent = + content !== null && + content !== undefined && + (typeof content === "string" ? content.length > 0 : content.length > 0); + if (!hasContent && !assistantMsg.tool_calls) { + continue; + } + params.push(assistantMsg); + } else if (msg.role === "toolResult") { + const imageBlocks: Array<{ type: "image_url"; image_url: { url: string } }> = []; + let j = i; + + for (; j < transformedMessages.length && transformedMessages[j].role === "toolResult"; j++) { + const toolMsg = transformedMessages[j] as ToolResultMessage; + + // Extract text and image content + const textResult = toolMsg.content + .filter(isTextContentBlock) + .map((block) => block.text) + .join("\n"); + const hasImages = toolMsg.content.some((c) => c.type === "image"); + + // Always send tool result with text (or placeholder if only images) + const hasText = textResult.length > 0; + // Some providers require the 'name' field in tool results + const toolResultMsg: ChatCompletionToolMessageParam = { + role: "tool", + content: sanitizeSurrogates(hasText ? textResult : "(see attached image)"), + tool_call_id: toolMsg.toolCallId, + }; + if (compat.requiresToolResultName && toolMsg.toolName) { + (toolResultMsg as any).name = toolMsg.toolName; + } + params.push(toolResultMsg); + + if (hasImages && model.input.includes("image")) { + for (const block of toolMsg.content) { + if (isImageContentBlock(block)) { + imageBlocks.push({ + type: "image_url", + image_url: { + url: `data:${block.mimeType};base64,${block.data}`, + }, + }); + } + } + } + } + + i = j - 1; + + if (imageBlocks.length > 0) { + if (compat.requiresAssistantAfterToolResult) { + params.push({ + role: "assistant", + content: "I have processed the tool results.", + }); + } + + params.push({ + role: "user", + content: [ + { + type: "text", + text: "Attached image(s) from tool result:", + }, + ...imageBlocks, + ], + }); + lastRole = "user"; + } else { + lastRole = "toolResult"; + } + continue; + } + + lastRole = msg.role; + } + + return params; +} + +function convertTools( + tools: Tool[], + compat: ResolvedOpenAICompletionsCompat, +): OpenAI.Chat.Completions.ChatCompletionTool[] { + return tools.map((tool) => ({ + type: "function", + function: { + name: tool.name, + description: tool.description, + parameters: tool.parameters as any, // TypeBox already generates JSON Schema + // Only include strict if provider supports it. Some reject unknown fields. + ...(compat.supportsStrictMode !== false && { strict: false }), + }, + })); +} + +function parseChunkUsage( + rawUsage: { + prompt_tokens?: number; + completion_tokens?: number; + prompt_cache_hit_tokens?: number; + prompt_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number }; + }, + model: Model<"openai-completions">, +): AssistantMessage["usage"] { + const promptTokens = rawUsage.prompt_tokens || 0; + const cacheReadTokens = rawUsage.prompt_tokens_details?.cached_tokens ?? rawUsage.prompt_cache_hit_tokens ?? 0; + const cacheWriteTokens = rawUsage.prompt_tokens_details?.cache_write_tokens || 0; + + // Follow documented OpenAI/OpenRouter semantics: cached_tokens is cache-read + // tokens (hits). OpenAI does not document or emit cache_write_tokens, but + // OpenRouter-compatible providers can include it as a separate write count. + // OpenRouter's own provider/tests affirm the separate mapping: + // https://github.com/OpenRouterTeam/ai-sdk-provider/pull/409 + // Do not subtract writes from cached_tokens, otherwise spec-compliant + // providers are under-reported. DS4 mirrors this contract too: + // https://github.com/antirez/ds4/pull/29 + const input = Math.max(0, promptTokens - cacheReadTokens - cacheWriteTokens); + // OpenAI completion_tokens already includes reasoning_tokens. + const outputTokens = rawUsage.completion_tokens || 0; + const usage: AssistantMessage["usage"] = { + input, + output: outputTokens, + cacheRead: cacheReadTokens, + cacheWrite: cacheWriteTokens, + totalTokens: input + outputTokens + cacheReadTokens + cacheWriteTokens, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; + calculateCost(model, usage); + return usage; +} + +function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | string): { + stopReason: StopReason; + errorMessage?: string; +} { + if (reason === null) return { stopReason: "stop" }; + switch (reason) { + case "stop": + case "end": + return { stopReason: "stop" }; + case "length": + return { stopReason: "length" }; + case "function_call": + case "tool_calls": + return { stopReason: "toolUse" }; + case "content_filter": + return { stopReason: "error", errorMessage: "Provider finish_reason: content_filter" }; + case "network_error": + return { stopReason: "error", errorMessage: "Provider finish_reason: network_error" }; + default: + return { + stopReason: "error", + errorMessage: `Provider finish_reason: ${reason}`, + }; + } +} + +/** + * Detect compatibility settings from provider and baseUrl for known providers. + * Provider takes precedence over URL-based detection since it's explicitly configured. + * Returns a fully resolved OpenAICompletionsCompat object with all fields set. + */ +function detectCompat(model: Model<"openai-completions">): ResolvedOpenAICompletionsCompat { + const provider = model.provider; + const baseUrl = model.baseUrl; + + const isZai = provider === "zai" || baseUrl.includes("api.z.ai"); + const isTogether = + provider === "together" || baseUrl.includes("api.together.ai") || baseUrl.includes("api.together.xyz"); + const isMoonshot = provider === "moonshotai" || provider === "moonshotai-cn" || baseUrl.includes("api.moonshot."); + const isCloudflareWorkersAI = provider === "cloudflare-workers-ai" || baseUrl.includes("api.cloudflare.com"); + const isCloudflareAiGateway = provider === "cloudflare-ai-gateway" || baseUrl.includes("gateway.ai.cloudflare.com"); + + const isNonStandard = + provider === "cerebras" || + baseUrl.includes("cerebras.ai") || + provider === "xai" || + baseUrl.includes("api.x.ai") || + isTogether || + baseUrl.includes("chutes.ai") || + baseUrl.includes("deepseek.com") || + isZai || + isMoonshot || + provider === "opencode" || + baseUrl.includes("opencode.ai") || + isCloudflareWorkersAI || + isCloudflareAiGateway; + + const useMaxTokens = baseUrl.includes("chutes.ai") || isMoonshot || isCloudflareAiGateway || isTogether; + + const isGrok = provider === "xai" || baseUrl.includes("api.x.ai"); + const isDeepSeek = provider === "deepseek" || baseUrl.includes("deepseek.com"); + const cacheControlFormat = provider === "openrouter" && model.id.startsWith("anthropic/") ? "anthropic" : undefined; + + return { + supportsStore: !isNonStandard, + supportsDeveloperRole: !isNonStandard, + supportsReasoningEffort: !isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAiGateway, + supportsUsageInStreaming: true, + maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens", + requiresToolResultName: false, + requiresAssistantAfterToolResult: false, + requiresThinkingAsText: false, + requiresReasoningContentOnAssistantMessages: isDeepSeek, + thinkingFormat: isDeepSeek + ? "deepseek" + : isZai + ? "zai" + : isTogether + ? "together" + : provider === "openrouter" || baseUrl.includes("openrouter.ai") + ? "openrouter" + : "openai", + openRouterRouting: {}, + vercelGatewayRouting: {}, + zaiToolStream: false, + supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway, + cacheControlFormat, + sendSessionAffinityHeaders: false, + supportsLongCacheRetention: !(isTogether || isCloudflareWorkersAI || isCloudflareAiGateway), + }; +} + +/** + * Get resolved compatibility settings for a model. + * Uses explicit model.compat if provided, otherwise auto-detects from provider/URL. + */ +function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompletionsCompat { + const detected = detectCompat(model); + if (!model.compat) return detected; + + return { + supportsStore: model.compat.supportsStore ?? detected.supportsStore, + supportsDeveloperRole: model.compat.supportsDeveloperRole ?? detected.supportsDeveloperRole, + supportsReasoningEffort: model.compat.supportsReasoningEffort ?? detected.supportsReasoningEffort, + supportsUsageInStreaming: model.compat.supportsUsageInStreaming ?? detected.supportsUsageInStreaming, + maxTokensField: model.compat.maxTokensField ?? detected.maxTokensField, + requiresToolResultName: model.compat.requiresToolResultName ?? detected.requiresToolResultName, + requiresAssistantAfterToolResult: + model.compat.requiresAssistantAfterToolResult ?? detected.requiresAssistantAfterToolResult, + requiresThinkingAsText: model.compat.requiresThinkingAsText ?? detected.requiresThinkingAsText, + requiresReasoningContentOnAssistantMessages: + model.compat.requiresReasoningContentOnAssistantMessages ?? + detected.requiresReasoningContentOnAssistantMessages, + thinkingFormat: model.compat.thinkingFormat ?? detected.thinkingFormat, + openRouterRouting: model.compat.openRouterRouting ?? {}, + vercelGatewayRouting: model.compat.vercelGatewayRouting ?? detected.vercelGatewayRouting, + zaiToolStream: model.compat.zaiToolStream ?? detected.zaiToolStream, + supportsStrictMode: model.compat.supportsStrictMode ?? detected.supportsStrictMode, + cacheControlFormat: model.compat.cacheControlFormat ?? detected.cacheControlFormat, + sendSessionAffinityHeaders: model.compat.sendSessionAffinityHeaders ?? detected.sendSessionAffinityHeaders, + supportsLongCacheRetention: model.compat.supportsLongCacheRetention ?? detected.supportsLongCacheRetention, + }; +} diff --git a/emain/ai/providers/openai-prompt-cache.ts b/emain/ai/providers/openai-prompt-cache.ts new file mode 100644 index 000000000..1c3355d52 --- /dev/null +++ b/emain/ai/providers/openai-prompt-cache.ts @@ -0,0 +1,8 @@ +export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64; + +export function clampOpenAIPromptCacheKey(key: string | undefined): string | undefined { + if (key === undefined) return undefined; + const chars = Array.from(key); + if (chars.length <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH) return key; + return chars.slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH).join(""); +} diff --git a/emain/ai/providers/openai-responses-shared.ts b/emain/ai/providers/openai-responses-shared.ts new file mode 100644 index 000000000..9f79ca519 --- /dev/null +++ b/emain/ai/providers/openai-responses-shared.ts @@ -0,0 +1,551 @@ +import type OpenAI from "openai"; +import type { + Tool as OpenAITool, + ResponseCreateParamsStreaming, + ResponseFunctionCallOutputItemList, + ResponseFunctionToolCall, + ResponseInput, + ResponseInputContent, + ResponseInputImage, + ResponseInputText, + ResponseOutputMessage, + ResponseReasoningItem, + ResponseStreamEvent, +} from "openai/resources/responses/responses.js"; +import { calculateCost } from "../models"; +import type { + Api, + AssistantMessage, + Context, + ImageContent, + Model, + StopReason, + TextContent, + TextSignatureV1, + ThinkingContent, + Tool, + ToolCall, + Usage, +} from "../types"; +import type { AssistantMessageEventStream } from "../utils/event-stream"; +import { shortHash } from "../utils/hash"; +import { parseStreamingJson } from "../utils/json-parse"; +import { sanitizeSurrogates } from "../utils/sanitize-unicode"; +import { transformMessages } from "./transform-messages"; + +// ============================================================================= +// Utilities +// ============================================================================= + +function encodeTextSignatureV1(id: string, phase?: TextSignatureV1["phase"]): string { + const payload: TextSignatureV1 = { v: 1, id }; + if (phase) payload.phase = phase; + return JSON.stringify(payload); +} + +function parseTextSignature( + signature: string | undefined, +): { id: string; phase?: TextSignatureV1["phase"] } | undefined { + if (!signature) return undefined; + if (signature.startsWith("{")) { + try { + const parsed = JSON.parse(signature) as Partial<TextSignatureV1>; + if (parsed.v === 1 && typeof parsed.id === "string") { + if (parsed.phase === "commentary" || parsed.phase === "final_answer") { + return { id: parsed.id, phase: parsed.phase }; + } + return { id: parsed.id }; + } + } catch { + // Fall through to legacy plain-string handling. + } + } + return { id: signature }; +} + +export interface OpenAIResponsesStreamOptions { + serviceTier?: ResponseCreateParamsStreaming["service_tier"]; + resolveServiceTier?: ( + responseServiceTier: ResponseCreateParamsStreaming["service_tier"] | undefined, + requestServiceTier: ResponseCreateParamsStreaming["service_tier"] | undefined, + ) => ResponseCreateParamsStreaming["service_tier"] | undefined; + applyServiceTierPricing?: ( + usage: Usage, + serviceTier: ResponseCreateParamsStreaming["service_tier"] | undefined, + ) => void; +} + +export interface ConvertResponsesMessagesOptions { + includeSystemPrompt?: boolean; +} + +export interface ConvertResponsesToolsOptions { + strict?: boolean | null; +} + +// ============================================================================= +// Message conversion +// ============================================================================= + +export function convertResponsesMessages<TApi extends Api>( + model: Model<TApi>, + context: Context, + allowedToolCallProviders: ReadonlySet<string>, + options?: ConvertResponsesMessagesOptions, +): ResponseInput { + const messages: ResponseInput = []; + + const normalizeIdPart = (part: string): string => { + const sanitized = part.replace(/[^a-zA-Z0-9_-]/g, "_"); + const normalized = sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized; + return normalized.replace(/_+$/, ""); + }; + + const buildForeignResponsesItemId = (itemId: string): string => { + const normalized = `fc_${shortHash(itemId)}`; + return normalized.length > 64 ? normalized.slice(0, 64) : normalized; + }; + + const normalizeToolCallId = (id: string, _targetModel: Model<TApi>, source: AssistantMessage): string => { + if (!allowedToolCallProviders.has(model.provider)) return normalizeIdPart(id); + if (!id.includes("|")) return normalizeIdPart(id); + const [callId, itemId] = id.split("|"); + const normalizedCallId = normalizeIdPart(callId); + const isForeignToolCall = source.provider !== model.provider || source.api !== model.api; + let normalizedItemId = isForeignToolCall ? buildForeignResponsesItemId(itemId) : normalizeIdPart(itemId); + // OpenAI Responses API requires item id to start with "fc" + if (!normalizedItemId.startsWith("fc_")) { + normalizedItemId = normalizeIdPart(`fc_${normalizedItemId}`); + } + return `${normalizedCallId}|${normalizedItemId}`; + }; + + const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId); + + const includeSystemPrompt = options?.includeSystemPrompt ?? true; + if (includeSystemPrompt && context.systemPrompt) { + const role = model.reasoning ? "developer" : "system"; + messages.push({ + role, + content: sanitizeSurrogates(context.systemPrompt), + }); + } + + let msgIndex = 0; + for (const msg of transformedMessages) { + if (msg.role === "user") { + if (typeof msg.content === "string") { + messages.push({ + role: "user", + content: [{ type: "input_text", text: sanitizeSurrogates(msg.content) }], + }); + } else { + const content: ResponseInputContent[] = msg.content.map((item): ResponseInputContent => { + if (item.type === "text") { + return { + type: "input_text", + text: sanitizeSurrogates(item.text), + } satisfies ResponseInputText; + } + return { + type: "input_image", + detail: "auto", + image_url: `data:${item.mimeType};base64,${item.data}`, + } satisfies ResponseInputImage; + }); + if (content.length === 0) continue; + messages.push({ + role: "user", + content, + }); + } + } else if (msg.role === "assistant") { + const output: ResponseInput = []; + const assistantMsg = msg as AssistantMessage; + const isDifferentModel = + assistantMsg.model !== model.id && + assistantMsg.provider === model.provider && + assistantMsg.api === model.api; + + for (const block of msg.content) { + if (block.type === "thinking") { + if (block.thinkingSignature) { + const reasoningItem = JSON.parse(block.thinkingSignature) as ResponseReasoningItem; + output.push(reasoningItem); + } + } else if (block.type === "text") { + const textBlock = block as TextContent; + const parsedSignature = parseTextSignature(textBlock.textSignature); + // OpenAI requires id to be max 64 characters + let msgId = parsedSignature?.id; + if (!msgId) { + msgId = `msg_${msgIndex}`; + } else if (msgId.length > 64) { + msgId = `msg_${shortHash(msgId)}`; + } + output.push({ + type: "message", + role: "assistant", + content: [{ type: "output_text", text: sanitizeSurrogates(textBlock.text), annotations: [] }], + status: "completed", + id: msgId, + phase: parsedSignature?.phase, + } satisfies ResponseOutputMessage); + } else if (block.type === "toolCall") { + const toolCall = block as ToolCall; + const [callId, itemIdRaw] = toolCall.id.split("|"); + let itemId: string | undefined = itemIdRaw; + + // For different-model messages, set id to undefined to avoid pairing validation. + // OpenAI tracks which fc_xxx IDs were paired with rs_xxx reasoning items. + // By omitting the id, we avoid triggering that validation (like cross-provider does). + if (isDifferentModel && itemId?.startsWith("fc_")) { + itemId = undefined; + } + + output.push({ + type: "function_call", + id: itemId, + call_id: callId, + name: toolCall.name, + arguments: JSON.stringify(toolCall.arguments), + }); + } + } + if (output.length === 0) continue; + messages.push(...output); + } else if (msg.role === "toolResult") { + const textResult = msg.content + .filter((c): c is TextContent => c.type === "text") + .map((c) => c.text) + .join("\n"); + const hasImages = msg.content.some((c): c is ImageContent => c.type === "image"); + const hasText = textResult.length > 0; + const [callId] = msg.toolCallId.split("|"); + + let output: string | ResponseFunctionCallOutputItemList; + if (hasImages && model.input.includes("image")) { + const contentParts: ResponseFunctionCallOutputItemList = []; + + if (hasText) { + contentParts.push({ + type: "input_text", + text: sanitizeSurrogates(textResult), + }); + } + + for (const block of msg.content) { + if (block.type === "image") { + contentParts.push({ + type: "input_image", + detail: "auto", + image_url: `data:${block.mimeType};base64,${block.data}`, + }); + } + } + + output = contentParts; + } else { + output = sanitizeSurrogates(hasText ? textResult : "(see attached image)"); + } + + messages.push({ + type: "function_call_output", + call_id: callId, + output, + }); + } + msgIndex++; + } + + return messages; +} + +// ============================================================================= +// Tool conversion +// ============================================================================= + +export function convertResponsesTools(tools: Tool[], options?: ConvertResponsesToolsOptions): OpenAITool[] { + const strict = options?.strict === undefined ? false : options.strict; + return tools.map((tool) => ({ + type: "function", + name: tool.name, + description: tool.description, + parameters: tool.parameters as any, // TypeBox already generates JSON Schema + strict, + })); +} + +// ============================================================================= +// Stream processing +// ============================================================================= + +export async function processResponsesStream<TApi extends Api>( + openaiStream: AsyncIterable<ResponseStreamEvent>, + output: AssistantMessage, + stream: AssistantMessageEventStream, + model: Model<TApi>, + options?: OpenAIResponsesStreamOptions, +): Promise<void> { + let currentItem: ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall | null = null; + let currentBlock: ThinkingContent | TextContent | (ToolCall & { partialJson: string }) | null = null; + const blocks = output.content; + const blockIndex = () => blocks.length - 1; + + for await (const event of openaiStream) { + if (event.type === "response.created") { + output.responseId = event.response.id; + } else if (event.type === "response.output_item.added") { + const item = event.item; + if (item.type === "reasoning") { + currentItem = item; + currentBlock = { type: "thinking", thinking: "" }; + output.content.push(currentBlock); + stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output }); + } else if (item.type === "message") { + currentItem = item; + currentBlock = { type: "text", text: "" }; + output.content.push(currentBlock); + stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output }); + } else if (item.type === "function_call") { + currentItem = item; + currentBlock = { + type: "toolCall", + id: `${item.call_id}|${item.id}`, + name: item.name, + arguments: {}, + partialJson: item.arguments || "", + }; + output.content.push(currentBlock); + stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output }); + } + } else if (event.type === "response.reasoning_summary_part.added") { + if (currentItem && currentItem.type === "reasoning") { + currentItem.summary = currentItem.summary || []; + currentItem.summary.push(event.part); + } + } else if (event.type === "response.reasoning_summary_text.delta") { + if (currentItem?.type === "reasoning" && currentBlock?.type === "thinking") { + currentItem.summary = currentItem.summary || []; + const lastPart = currentItem.summary[currentItem.summary.length - 1]; + if (lastPart) { + currentBlock.thinking += event.delta; + lastPart.text += event.delta; + stream.push({ + type: "thinking_delta", + contentIndex: blockIndex(), + delta: event.delta, + partial: output, + }); + } + } + } else if (event.type === "response.reasoning_summary_part.done") { + if (currentItem?.type === "reasoning" && currentBlock?.type === "thinking") { + currentItem.summary = currentItem.summary || []; + const lastPart = currentItem.summary[currentItem.summary.length - 1]; + if (lastPart) { + currentBlock.thinking += "\n\n"; + lastPart.text += "\n\n"; + stream.push({ + type: "thinking_delta", + contentIndex: blockIndex(), + delta: "\n\n", + partial: output, + }); + } + } + } else if (event.type === "response.reasoning_text.delta") { + if (currentItem?.type === "reasoning" && currentBlock?.type === "thinking") { + currentBlock.thinking += event.delta; + stream.push({ + type: "thinking_delta", + contentIndex: blockIndex(), + delta: event.delta, + partial: output, + }); + } + } else if (event.type === "response.content_part.added") { + if (currentItem?.type === "message") { + currentItem.content = currentItem.content || []; + // Filter out ReasoningText, only accept output_text and refusal + if (event.part.type === "output_text" || event.part.type === "refusal") { + currentItem.content.push(event.part); + } + } + } else if (event.type === "response.output_text.delta") { + if (currentItem?.type === "message" && currentBlock?.type === "text") { + if (!currentItem.content || currentItem.content.length === 0) { + continue; + } + const lastPart = currentItem.content[currentItem.content.length - 1]; + if (lastPart?.type === "output_text") { + currentBlock.text += event.delta; + lastPart.text += event.delta; + stream.push({ + type: "text_delta", + contentIndex: blockIndex(), + delta: event.delta, + partial: output, + }); + } + } + } else if (event.type === "response.refusal.delta") { + if (currentItem?.type === "message" && currentBlock?.type === "text") { + if (!currentItem.content || currentItem.content.length === 0) { + continue; + } + const lastPart = currentItem.content[currentItem.content.length - 1]; + if (lastPart?.type === "refusal") { + currentBlock.text += event.delta; + lastPart.refusal += event.delta; + stream.push({ + type: "text_delta", + contentIndex: blockIndex(), + delta: event.delta, + partial: output, + }); + } + } + } else if (event.type === "response.function_call_arguments.delta") { + if (currentItem?.type === "function_call" && currentBlock?.type === "toolCall") { + currentBlock.partialJson += event.delta; + currentBlock.arguments = parseStreamingJson(currentBlock.partialJson); + stream.push({ + type: "toolcall_delta", + contentIndex: blockIndex(), + delta: event.delta, + partial: output, + }); + } + } else if (event.type === "response.function_call_arguments.done") { + if (currentItem?.type === "function_call" && currentBlock?.type === "toolCall") { + const previousPartialJson = currentBlock.partialJson; + currentBlock.partialJson = event.arguments; + currentBlock.arguments = parseStreamingJson(currentBlock.partialJson); + + if (event.arguments.startsWith(previousPartialJson)) { + const delta = event.arguments.slice(previousPartialJson.length); + if (delta.length > 0) { + stream.push({ + type: "toolcall_delta", + contentIndex: blockIndex(), + delta, + partial: output, + }); + } + } + } + } else if (event.type === "response.output_item.done") { + const item = event.item; + + if (item.type === "reasoning" && currentBlock?.type === "thinking") { + const summaryText = item.summary?.map((s) => s.text).join("\n\n") || ""; + const contentText = item.content?.map((c) => c.text).join("\n\n") || ""; + currentBlock.thinking = summaryText || contentText || currentBlock.thinking; + currentBlock.thinkingSignature = JSON.stringify(item); + stream.push({ + type: "thinking_end", + contentIndex: blockIndex(), + content: currentBlock.thinking, + partial: output, + }); + currentBlock = null; + } else if (item.type === "message" && currentBlock?.type === "text") { + currentBlock.text = item.content.map((c) => (c.type === "output_text" ? c.text : c.refusal)).join(""); + currentBlock.textSignature = encodeTextSignatureV1(item.id, item.phase ?? undefined); + stream.push({ + type: "text_end", + contentIndex: blockIndex(), + content: currentBlock.text, + partial: output, + }); + currentBlock = null; + } else if (item.type === "function_call") { + const args = + currentBlock?.type === "toolCall" && currentBlock.partialJson + ? parseStreamingJson(currentBlock.partialJson) + : parseStreamingJson(item.arguments || "{}"); + + let toolCall: ToolCall; + if (currentBlock?.type === "toolCall") { + // Finalize in-place and strip the scratch buffer so replay only + // carries parsed arguments. + currentBlock.arguments = args; + delete (currentBlock as { partialJson?: string }).partialJson; + toolCall = currentBlock; + } else { + toolCall = { + type: "toolCall", + id: `${item.call_id}|${item.id}`, + name: item.name, + arguments: args, + }; + } + + currentBlock = null; + stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output }); + } + } else if (event.type === "response.completed") { + const response = event.response; + if (response?.id) { + output.responseId = response.id; + } + if (response?.usage) { + const cachedTokens = response.usage.input_tokens_details?.cached_tokens || 0; + output.usage = { + // OpenAI includes cached tokens in input_tokens, so subtract to get non-cached input + input: (response.usage.input_tokens || 0) - cachedTokens, + output: response.usage.output_tokens || 0, + cacheRead: cachedTokens, + cacheWrite: 0, + totalTokens: response.usage.total_tokens || 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; + } + calculateCost(model, output.usage); + if (options?.applyServiceTierPricing) { + const serviceTier = options.resolveServiceTier + ? options.resolveServiceTier(response?.service_tier, options.serviceTier) + : (response?.service_tier ?? options.serviceTier); + options.applyServiceTierPricing(output.usage, serviceTier); + } + // Map status to stop reason + output.stopReason = mapStopReason(response?.status); + if (output.content.some((b) => b.type === "toolCall") && output.stopReason === "stop") { + output.stopReason = "toolUse"; + } + } else if (event.type === "error") { + throw new Error(`Error Code ${event.code}: ${event.message}` || "Unknown error"); + } else if (event.type === "response.failed") { + const error = event.response?.error; + const details = event.response?.incomplete_details; + const msg = error + ? `${error.code || "unknown"}: ${error.message || "no message"}` + : details?.reason + ? `incomplete: ${details.reason}` + : "Unknown error (no error details in response)"; + throw new Error(msg); + } + } +} + +function mapStopReason(status: OpenAI.Responses.ResponseStatus | undefined): StopReason { + if (!status) return "stop"; + switch (status) { + case "completed": + return "stop"; + case "incomplete": + return "length"; + case "failed": + case "cancelled": + return "error"; + // These two are wonky ... + case "in_progress": + case "queued": + return "stop"; + default: { + const _exhaustive: never = status; + throw new Error(`Unhandled stop reason: ${_exhaustive}`); + } + } +} diff --git a/emain/ai/providers/openai-responses.ts b/emain/ai/providers/openai-responses.ts new file mode 100644 index 000000000..17fb3453b --- /dev/null +++ b/emain/ai/providers/openai-responses.ts @@ -0,0 +1,312 @@ +import OpenAI from "openai"; +import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js"; +import { getEnvApiKey } from "../env-api-keys"; +import { clampThinkingLevel } from "../models"; +import type { + Api, + AssistantMessage, + CacheRetention, + Context, + Model, + OpenAIResponsesCompat, + SimpleStreamOptions, + StreamFunction, + StreamOptions, + Usage, +} from "../types"; +import { AssistantMessageEventStream } from "../utils/event-stream"; +import { headersToRecord } from "../utils/headers"; +import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare"; +import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers"; +import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache"; +import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared"; +import { buildBaseOptions } from "./simple-options"; + +const OPENAI_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]); + +/** + * Resolve cache retention preference. + * Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility. + */ +function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention { + if (cacheRetention) { + return cacheRetention; + } + if (typeof process !== "undefined" && process.env.PI_CACHE_RETENTION === "long") { + return "long"; + } + return "short"; +} + +function getCompat(model: Model<"openai-responses">): Required<OpenAIResponsesCompat> { + return { + sendSessionIdHeader: model.compat?.sendSessionIdHeader ?? true, + supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true, + }; +} + +function getPromptCacheRetention( + compat: Required<OpenAIResponsesCompat>, + cacheRetention: CacheRetention, +): "24h" | undefined { + return cacheRetention === "long" && compat.supportsLongCacheRetention ? "24h" : undefined; +} + +function formatOpenAIResponsesError(error: unknown): string { + if (error instanceof Error) { + const status = (error as Error & { status?: unknown }).status; + const statusCode = typeof status === "number" ? status : undefined; + if (statusCode !== undefined) { + return `OpenAI API error (${statusCode}): ${error.message}`; + } + return error.message; + } + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} + +// OpenAI Responses-specific options +export interface OpenAIResponsesOptions extends StreamOptions { + reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh"; + reasoningSummary?: "auto" | "detailed" | "concise" | null; + serviceTier?: ResponseCreateParamsStreaming["service_tier"]; +} + +/** + * Generate function for OpenAI Responses API + */ +export const streamOpenAIResponses: StreamFunction<"openai-responses", OpenAIResponsesOptions> = ( + model: Model<"openai-responses">, + context: Context, + options?: OpenAIResponsesOptions, +): AssistantMessageEventStream => { + const stream = new AssistantMessageEventStream(); + + // Start async processing + (async () => { + const output: AssistantMessage = { + role: "assistant", + content: [], + api: model.api as Api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + + try { + // Create OpenAI client + const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; + const cacheRetention = resolveCacheRetention(options?.cacheRetention); + const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId; + const client = createClient(model, context, apiKey, options?.headers, cacheSessionId); + let params = buildParams(model, context, options); + const nextParams = await options?.onPayload?.(params, model); + if (nextParams !== undefined) { + params = nextParams as ResponseCreateParamsStreaming; + } + const requestOptions = { + ...(options?.signal ? { signal: options.signal } : {}), + ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), + ...(options?.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), + }; + const { data: openaiStream, response } = await client.responses.create(params, requestOptions).withResponse(); + await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model); + stream.push({ type: "start", partial: output }); + + await processResponsesStream(openaiStream, output, stream, model, { + serviceTier: options?.serviceTier, + applyServiceTierPricing: (usage, serviceTier) => applyServiceTierPricing(usage, serviceTier, model), + }); + + if (options?.signal?.aborted) { + throw new Error("Request was aborted"); + } + + if (output.stopReason === "aborted" || output.stopReason === "error") { + throw new Error("An unknown error occurred"); + } + + stream.push({ type: "done", reason: output.stopReason, message: output }); + stream.end(); + } catch (error) { + for (const block of output.content) { + delete (block as { index?: number }).index; + // partialJson is only a streaming scratch buffer; never persist it. + delete (block as { partialJson?: string }).partialJson; + } + output.stopReason = options?.signal?.aborted ? "aborted" : "error"; + output.errorMessage = formatOpenAIResponsesError(error); + stream.push({ type: "error", reason: output.stopReason, error: output }); + stream.end(); + } + })(); + + return stream; +}; + +export const streamSimpleOpenAIResponses: StreamFunction<"openai-responses", SimpleStreamOptions> = ( + model: Model<"openai-responses">, + context: Context, + options?: SimpleStreamOptions, +): AssistantMessageEventStream => { + const apiKey = options?.apiKey || getEnvApiKey(model.provider); + if (!apiKey) { + throw new Error(`No API key for provider: ${model.provider}`); + } + + const base = buildBaseOptions(model, options, apiKey); + const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined; + const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning; + + return streamOpenAIResponses(model, context, { + ...base, + reasoningEffort, + } satisfies OpenAIResponsesOptions); +}; + +function createClient( + model: Model<"openai-responses">, + context: Context, + apiKey?: string, + optionsHeaders?: Record<string, string>, + sessionId?: string, +) { + if (!apiKey) { + if (!process.env.OPENAI_API_KEY) { + throw new Error( + "OpenAI API key is required. Set OPENAI_API_KEY environment variable or pass it as an argument.", + ); + } + apiKey = process.env.OPENAI_API_KEY; + } + + const compat = getCompat(model); + const headers = { ...model.headers }; + if (model.provider === "github-copilot") { + const hasImages = hasCopilotVisionInput(context.messages); + const copilotHeaders = buildCopilotDynamicHeaders({ + messages: context.messages, + hasImages, + }); + Object.assign(headers, copilotHeaders); + } + + if (sessionId) { + if (compat.sendSessionIdHeader) { + headers.session_id = sessionId; + } + headers["x-client-request-id"] = sessionId; + } + + // Merge options headers last so they can override defaults + if (optionsHeaders) { + Object.assign(headers, optionsHeaders); + } + + const defaultHeaders = + model.provider === "cloudflare-ai-gateway" + ? { + ...headers, + Authorization: headers.Authorization ?? null, + "cf-aig-authorization": `Bearer ${apiKey}`, + } + : headers; + + return new OpenAI({ + apiKey, + baseURL: isCloudflareProvider(model.provider) ? resolveCloudflareBaseUrl(model) : model.baseUrl, + dangerouslyAllowBrowser: true, + defaultHeaders, + }); +} + +function buildParams(model: Model<"openai-responses">, context: Context, options?: OpenAIResponsesOptions) { + const messages = convertResponsesMessages(model, context, OPENAI_TOOL_CALL_PROVIDERS); + + const cacheRetention = resolveCacheRetention(options?.cacheRetention); + const compat = getCompat(model); + const params: ResponseCreateParamsStreaming = { + model: model.id, + input: messages, + stream: true, + prompt_cache_key: cacheRetention === "none" ? undefined : clampOpenAIPromptCacheKey(options?.sessionId), + prompt_cache_retention: getPromptCacheRetention(compat, cacheRetention), + store: false, + }; + + if (options?.maxTokens) { + params.max_output_tokens = options?.maxTokens; + } + + if (options?.temperature !== undefined) { + params.temperature = options?.temperature; + } + + if (options?.serviceTier !== undefined) { + params.service_tier = options.serviceTier; + } + + if (context.tools && context.tools.length > 0) { + params.tools = convertResponsesTools(context.tools); + } + + if (model.reasoning) { + if (options?.reasoningEffort || options?.reasoningSummary) { + const effort = options?.reasoningEffort + ? (model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort) + : "medium"; + params.reasoning = { + effort: effort as NonNullable<typeof params.reasoning>["effort"], + summary: options?.reasoningSummary || "auto", + }; + params.include = ["reasoning.encrypted_content"]; + } else if (model.provider !== "github-copilot" && model.thinkingLevelMap?.off !== null) { + params.reasoning = { + effort: (model.thinkingLevelMap?.off ?? "none") as NonNullable<typeof params.reasoning>["effort"], + }; + } + } + + return params; +} + +function getServiceTierCostMultiplier( + model: Pick<Model<"openai-responses">, "id">, + serviceTier: ResponseCreateParamsStreaming["service_tier"] | undefined, +): number { + switch (serviceTier) { + case "flex": + return 0.5; + case "priority": + return model.id === "gpt-5.5" ? 2.5 : 2; + default: + return 1; + } +} + +function applyServiceTierPricing( + usage: Usage, + serviceTier: ResponseCreateParamsStreaming["service_tier"] | undefined, + model: Pick<Model<"openai-responses">, "id">, +) { + const multiplier = getServiceTierCostMultiplier(model, serviceTier); + if (multiplier === 1) return; + + usage.cost.input *= multiplier; + usage.cost.output *= multiplier; + usage.cost.cacheRead *= multiplier; + usage.cost.cacheWrite *= multiplier; + usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite; +} diff --git a/emain/ai/providers/register-builtins.ts b/emain/ai/providers/register-builtins.ts new file mode 100644 index 000000000..712eb159f --- /dev/null +++ b/emain/ai/providers/register-builtins.ts @@ -0,0 +1,247 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +// +// Built-in API provider registration. Trimmed to the four providers +// crest actively uses (openai-responses, openai-completions, +// anthropic-messages, google-generative-ai). The OpenRouter live list +// routes through openai-completions with a custom baseURL, so no +// dedicated OpenRouter provider entry is required. +// +// Dropped from upstream pi-ai (delete the file under providers/ when +// adding back): amazon-bedrock, azure-openai-responses, cloudflare, +// faux, google-vertex, mistral, openai-codex-responses, images. +// Re-introduce one of these by: +// 1. Copying the provider file back from pi v0.75.5 packages/ai/src/providers/. +// 2. Adding a streamX / streamSimpleX import + registerApiProvider call below. +// 3. Updating crest's catalog + secret store if the provider needs auth. + +import { clearApiProviders, registerApiProvider } from "../api-registry"; +import type { + Api, + AssistantMessage, + AssistantMessageEvent, + Context, + Model, + SimpleStreamOptions, + StreamFunction, + StreamOptions, +} from "../types"; +import { AssistantMessageEventStream } from "../utils/event-stream"; +import type { AnthropicOptions } from "./anthropic"; +import type { GoogleOptions } from "./google"; +import type { OpenAICompletionsOptions } from "./openai-completions"; +import type { OpenAIResponsesOptions } from "./openai-responses"; + +interface LazyProviderModule< + TApi extends Api, + TOptions extends StreamOptions, + TSimpleOptions extends SimpleStreamOptions, +> { + stream: (model: Model<TApi>, context: Context, options?: TOptions) => AsyncIterable<AssistantMessageEvent>; + streamSimple: ( + model: Model<TApi>, + context: Context, + options?: TSimpleOptions, + ) => AsyncIterable<AssistantMessageEvent>; +} + +interface AnthropicProviderModule { + streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOptions>; + streamSimpleAnthropic: StreamFunction<"anthropic-messages", SimpleStreamOptions>; +} + +interface GoogleProviderModule { + streamGoogle: StreamFunction<"google-generative-ai", GoogleOptions>; + streamSimpleGoogle: StreamFunction<"google-generative-ai", SimpleStreamOptions>; +} + +interface OpenAICompletionsProviderModule { + streamOpenAICompletions: StreamFunction<"openai-completions", OpenAICompletionsOptions>; + streamSimpleOpenAICompletions: StreamFunction<"openai-completions", SimpleStreamOptions>; +} + +interface OpenAIResponsesProviderModule { + streamOpenAIResponses: StreamFunction<"openai-responses", OpenAIResponsesOptions>; + streamSimpleOpenAIResponses: StreamFunction<"openai-responses", SimpleStreamOptions>; +} + +let anthropicProviderModulePromise: + | Promise<LazyProviderModule<"anthropic-messages", AnthropicOptions, SimpleStreamOptions>> + | undefined; +let googleProviderModulePromise: + | Promise<LazyProviderModule<"google-generative-ai", GoogleOptions, SimpleStreamOptions>> + | undefined; +let openAICompletionsProviderModulePromise: + | Promise<LazyProviderModule<"openai-completions", OpenAICompletionsOptions, SimpleStreamOptions>> + | undefined; +let openAIResponsesProviderModulePromise: + | Promise<LazyProviderModule<"openai-responses", OpenAIResponsesOptions, SimpleStreamOptions>> + | undefined; + +function forwardStream(target: AssistantMessageEventStream, source: AsyncIterable<AssistantMessageEvent>): void { + (async () => { + for await (const event of source) { + target.push(event); + } + target.end(); + })(); +} + +function createLazyLoadErrorMessage<TApi extends Api>(model: Model<TApi>, error: unknown): AssistantMessage { + return { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp: Date.now(), + }; +} + +function createLazyStream<TApi extends Api, TOptions extends StreamOptions, TSimpleOptions extends SimpleStreamOptions>( + loadModule: () => Promise<LazyProviderModule<TApi, TOptions, TSimpleOptions>>, +): StreamFunction<TApi, TOptions> { + return (model, context, options) => { + const outer = new AssistantMessageEventStream(); + + loadModule() + .then((module) => { + const inner = module.stream(model, context, options); + forwardStream(outer, inner); + }) + .catch((error) => { + const message = createLazyLoadErrorMessage(model, error); + outer.push({ type: "error", reason: "error", error: message }); + outer.end(message); + }); + + return outer; + }; +} + +function createLazySimpleStream< + TApi extends Api, + TOptions extends StreamOptions, + TSimpleOptions extends SimpleStreamOptions, +>(loadModule: () => Promise<LazyProviderModule<TApi, TOptions, TSimpleOptions>>): StreamFunction<TApi, TSimpleOptions> { + return (model, context, options) => { + const outer = new AssistantMessageEventStream(); + + loadModule() + .then((module) => { + const inner = module.streamSimple(model, context, options); + forwardStream(outer, inner); + }) + .catch((error) => { + const message = createLazyLoadErrorMessage(model, error); + outer.push({ type: "error", reason: "error", error: message }); + outer.end(message); + }); + + return outer; + }; +} + +function loadAnthropicProviderModule(): Promise< + LazyProviderModule<"anthropic-messages", AnthropicOptions, SimpleStreamOptions> +> { + anthropicProviderModulePromise ||= import("./anthropic").then((module) => { + const provider = module as AnthropicProviderModule; + return { + stream: provider.streamAnthropic, + streamSimple: provider.streamSimpleAnthropic, + }; + }); + return anthropicProviderModulePromise; +} + +function loadGoogleProviderModule(): Promise< + LazyProviderModule<"google-generative-ai", GoogleOptions, SimpleStreamOptions> +> { + googleProviderModulePromise ||= import("./google").then((module) => { + const provider = module as GoogleProviderModule; + return { + stream: provider.streamGoogle, + streamSimple: provider.streamSimpleGoogle, + }; + }); + return googleProviderModulePromise; +} + +function loadOpenAICompletionsProviderModule(): Promise< + LazyProviderModule<"openai-completions", OpenAICompletionsOptions, SimpleStreamOptions> +> { + openAICompletionsProviderModulePromise ||= import("./openai-completions").then((module) => { + const provider = module as OpenAICompletionsProviderModule; + return { + stream: provider.streamOpenAICompletions, + streamSimple: provider.streamSimpleOpenAICompletions, + }; + }); + return openAICompletionsProviderModulePromise; +} + +function loadOpenAIResponsesProviderModule(): Promise< + LazyProviderModule<"openai-responses", OpenAIResponsesOptions, SimpleStreamOptions> +> { + openAIResponsesProviderModulePromise ||= import("./openai-responses").then((module) => { + const provider = module as OpenAIResponsesProviderModule; + return { + stream: provider.streamOpenAIResponses, + streamSimple: provider.streamSimpleOpenAIResponses, + }; + }); + return openAIResponsesProviderModulePromise; +} + +export const streamAnthropic = createLazyStream(loadAnthropicProviderModule); +export const streamSimpleAnthropic = createLazySimpleStream(loadAnthropicProviderModule); +export const streamGoogle = createLazyStream(loadGoogleProviderModule); +export const streamSimpleGoogle = createLazySimpleStream(loadGoogleProviderModule); +export const streamOpenAICompletions = createLazyStream(loadOpenAICompletionsProviderModule); +export const streamSimpleOpenAICompletions = createLazySimpleStream(loadOpenAICompletionsProviderModule); +export const streamOpenAIResponses = createLazyStream(loadOpenAIResponsesProviderModule); +export const streamSimpleOpenAIResponses = createLazySimpleStream(loadOpenAIResponsesProviderModule); + +export function registerBuiltInApiProviders(): void { + registerApiProvider({ + api: "anthropic-messages", + stream: streamAnthropic, + streamSimple: streamSimpleAnthropic, + }); + + registerApiProvider({ + api: "openai-completions", + stream: streamOpenAICompletions, + streamSimple: streamSimpleOpenAICompletions, + }); + + registerApiProvider({ + api: "openai-responses", + stream: streamOpenAIResponses, + streamSimple: streamSimpleOpenAIResponses, + }); + + registerApiProvider({ + api: "google-generative-ai", + stream: streamGoogle, + streamSimple: streamSimpleGoogle, + }); +} + +export function resetApiProviders(): void { + clearApiProviders(); + registerBuiltInApiProviders(); +} + +registerBuiltInApiProviders(); diff --git a/emain/ai/providers/simple-options.ts b/emain/ai/providers/simple-options.ts new file mode 100644 index 000000000..27ac4fbd0 --- /dev/null +++ b/emain/ai/providers/simple-options.ts @@ -0,0 +1,52 @@ +import type { Api, Model, SimpleStreamOptions, StreamOptions, ThinkingBudgets, ThinkingLevel } from "../types"; + +export function buildBaseOptions(_model: Model<Api>, options?: SimpleStreamOptions, apiKey?: string): StreamOptions { + return { + temperature: options?.temperature, + maxTokens: options?.maxTokens, + signal: options?.signal, + apiKey: apiKey || options?.apiKey, + transport: options?.transport, + cacheRetention: options?.cacheRetention, + sessionId: options?.sessionId, + headers: options?.headers, + onPayload: options?.onPayload, + onResponse: options?.onResponse, + timeoutMs: options?.timeoutMs, + maxRetries: options?.maxRetries, + maxRetryDelayMs: options?.maxRetryDelayMs, + metadata: options?.metadata, + }; +} + +export function clampReasoning(effort: ThinkingLevel | undefined): Exclude<ThinkingLevel, "xhigh"> | undefined { + return effort === "xhigh" ? "high" : effort; +} + +export function adjustMaxTokensForThinking( + // Undefined means no explicit caller cap. Use the model cap and fit thinking inside it. + baseMaxTokens: number | undefined, + modelMaxTokens: number, + reasoningLevel: ThinkingLevel, + customBudgets?: ThinkingBudgets, +): { maxTokens: number; thinkingBudget: number } { + const defaultBudgets: ThinkingBudgets = { + minimal: 1024, + low: 2048, + medium: 8192, + high: 16384, + }; + const budgets = { ...defaultBudgets, ...customBudgets }; + + const minOutputTokens = 1024; + const level = clampReasoning(reasoningLevel)!; + let thinkingBudget = budgets[level]!; + const maxTokens = + baseMaxTokens === undefined ? modelMaxTokens : Math.min(baseMaxTokens + thinkingBudget, modelMaxTokens); + + if (maxTokens <= thinkingBudget) { + thinkingBudget = Math.max(0, maxTokens - minOutputTokens); + } + + return { maxTokens, thinkingBudget }; +} diff --git a/emain/ai/providers/transform-messages.ts b/emain/ai/providers/transform-messages.ts new file mode 100644 index 000000000..8e49f5c42 --- /dev/null +++ b/emain/ai/providers/transform-messages.ts @@ -0,0 +1,220 @@ +import type { + Api, + AssistantMessage, + ImageContent, + Message, + Model, + TextContent, + ToolCall, + ToolResultMessage, +} from "../types"; + +const NON_VISION_USER_IMAGE_PLACEHOLDER = "(image omitted: model does not support images)"; +const NON_VISION_TOOL_IMAGE_PLACEHOLDER = "(tool image omitted: model does not support images)"; + +function replaceImagesWithPlaceholder(content: (TextContent | ImageContent)[], placeholder: string): TextContent[] { + const result: TextContent[] = []; + let previousWasPlaceholder = false; + + for (const block of content) { + if (block.type === "image") { + if (!previousWasPlaceholder) { + result.push({ type: "text", text: placeholder }); + } + previousWasPlaceholder = true; + continue; + } + + result.push(block); + previousWasPlaceholder = block.text === placeholder; + } + + return result; +} + +function downgradeUnsupportedImages<TApi extends Api>(messages: Message[], model: Model<TApi>): Message[] { + if (model.input.includes("image")) { + return messages; + } + + return messages.map((msg) => { + if (msg.role === "user" && Array.isArray(msg.content)) { + return { + ...msg, + content: replaceImagesWithPlaceholder(msg.content, NON_VISION_USER_IMAGE_PLACEHOLDER), + }; + } + + if (msg.role === "toolResult") { + return { + ...msg, + content: replaceImagesWithPlaceholder(msg.content, NON_VISION_TOOL_IMAGE_PLACEHOLDER), + }; + } + + return msg; + }); +} + +/** + * Normalize tool call ID for cross-provider compatibility. + * OpenAI Responses API generates IDs that are 450+ chars with special characters like `|`. + * Anthropic APIs require IDs matching ^[a-zA-Z0-9_-]+$ (max 64 chars). + */ +export function transformMessages<TApi extends Api>( + messages: Message[], + model: Model<TApi>, + normalizeToolCallId?: (id: string, model: Model<TApi>, source: AssistantMessage) => string, +): Message[] { + // Build a map of original tool call IDs to normalized IDs + const toolCallIdMap = new Map<string, string>(); + const imageAwareMessages = downgradeUnsupportedImages(messages, model); + + // First pass: transform messages (unsupported image downgrade, thinking blocks, tool call ID normalization) + const transformed = imageAwareMessages.map((msg) => { + // User messages pass through unchanged + if (msg.role === "user") { + return msg; + } + + // Handle toolResult messages - normalize toolCallId if we have a mapping + if (msg.role === "toolResult") { + const normalizedId = toolCallIdMap.get(msg.toolCallId); + if (normalizedId && normalizedId !== msg.toolCallId) { + return { ...msg, toolCallId: normalizedId }; + } + return msg; + } + + // Assistant messages need transformation check + if (msg.role === "assistant") { + const assistantMsg = msg as AssistantMessage; + const isSameModel = + assistantMsg.provider === model.provider && + assistantMsg.api === model.api && + assistantMsg.model === model.id; + + const transformedContent = assistantMsg.content.flatMap((block) => { + if (block.type === "thinking") { + // Redacted thinking is opaque encrypted content, only valid for the same model. + // Drop it for cross-model to avoid API errors. + if (block.redacted) { + return isSameModel ? block : []; + } + // For same model: keep thinking blocks with signatures (needed for replay) + // even if the thinking text is empty (OpenAI encrypted reasoning) + if (isSameModel && block.thinkingSignature) return block; + // Skip empty thinking blocks, convert others to plain text + if (!block.thinking || block.thinking.trim() === "") return []; + if (isSameModel) return block; + return { + type: "text" as const, + text: block.thinking, + }; + } + + if (block.type === "text") { + if (isSameModel) return block; + return { + type: "text" as const, + text: block.text, + }; + } + + if (block.type === "toolCall") { + const toolCall = block as ToolCall; + let normalizedToolCall: ToolCall = toolCall; + + if (!isSameModel && toolCall.thoughtSignature) { + normalizedToolCall = { ...toolCall }; + delete (normalizedToolCall as { thoughtSignature?: string }).thoughtSignature; + } + + if (!isSameModel && normalizeToolCallId) { + const normalizedId = normalizeToolCallId(toolCall.id, model, assistantMsg); + if (normalizedId !== toolCall.id) { + toolCallIdMap.set(toolCall.id, normalizedId); + normalizedToolCall = { ...normalizedToolCall, id: normalizedId }; + } + } + + return normalizedToolCall; + } + + return block; + }); + + return { + ...assistantMsg, + content: transformedContent, + }; + } + return msg; + }); + + // Second pass: insert synthetic empty tool results for orphaned tool calls + // This preserves thinking signatures and satisfies API requirements + const result: Message[] = []; + let pendingToolCalls: ToolCall[] = []; + let existingToolResultIds = new Set<string>(); + const insertSyntheticToolResults = () => { + if (pendingToolCalls.length > 0) { + for (const tc of pendingToolCalls) { + if (!existingToolResultIds.has(tc.id)) { + result.push({ + role: "toolResult", + toolCallId: tc.id, + toolName: tc.name, + content: [{ type: "text", text: "No result provided" }], + isError: true, + timestamp: Date.now(), + } as ToolResultMessage); + } + } + pendingToolCalls = []; + existingToolResultIds = new Set(); + } + }; + + for (let i = 0; i < transformed.length; i++) { + const msg = transformed[i]; + + if (msg.role === "assistant") { + // If we have pending orphaned tool calls from a previous assistant, insert synthetic results now + insertSyntheticToolResults(); + + // Skip errored/aborted assistant messages entirely. + // These are incomplete turns that shouldn't be replayed: + // - May have partial content (reasoning without message, incomplete tool calls) + // - Replaying them can cause API errors (e.g., OpenAI "reasoning without following item") + // - The model should retry from the last valid state + const assistantMsg = msg as AssistantMessage; + if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") { + continue; + } + + // Track tool calls from this assistant message + const toolCalls = assistantMsg.content.filter((b) => b.type === "toolCall") as ToolCall[]; + if (toolCalls.length > 0) { + pendingToolCalls = toolCalls; + existingToolResultIds = new Set(); + } + + result.push(msg); + } else if (msg.role === "toolResult") { + existingToolResultIds.add(msg.toolCallId); + result.push(msg); + } else if (msg.role === "user") { + // User message interrupts tool flow - insert synthetic results for orphaned calls + insertSyntheticToolResults(); + result.push(msg); + } else { + result.push(msg); + } + } + + // If the conversation ends with unresolved tool calls, synthesize results now. + insertSyntheticToolResults(); + + return result; +} diff --git a/emain/ai/session-resources.ts b/emain/ai/session-resources.ts new file mode 100644 index 000000000..d63009268 --- /dev/null +++ b/emain/ai/session-resources.ts @@ -0,0 +1,24 @@ +export type SessionResourceCleanup = (sessionId?: string) => void; + +const sessionResourceCleanups = new Set<SessionResourceCleanup>(); + +export function registerSessionResourceCleanup(cleanup: SessionResourceCleanup): () => void { + sessionResourceCleanups.add(cleanup); + return () => { + sessionResourceCleanups.delete(cleanup); + }; +} + +export function cleanupSessionResources(sessionId?: string): void { + const errors: unknown[] = []; + for (const cleanup of sessionResourceCleanups) { + try { + cleanup(sessionId); + } catch (error) { + errors.push(error); + } + } + if (errors.length > 0) { + throw new AggregateError(errors, "Failed to cleanup session resources"); + } +} diff --git a/emain/ai/stream.ts b/emain/ai/stream.ts new file mode 100644 index 000000000..8ce5aa076 --- /dev/null +++ b/emain/ai/stream.ts @@ -0,0 +1,59 @@ +import "./providers/register-builtins"; + +import { getApiProvider } from "./api-registry"; +import type { + Api, + AssistantMessage, + AssistantMessageEventStream, + Context, + Model, + ProviderStreamOptions, + SimpleStreamOptions, + StreamOptions, +} from "./types"; + +export { getEnvApiKey } from "./env-api-keys"; + +function resolveApiProvider(api: Api) { + const provider = getApiProvider(api); + if (!provider) { + throw new Error(`No API provider registered for api: ${api}`); + } + return provider; +} + +export function stream<TApi extends Api>( + model: Model<TApi>, + context: Context, + options?: ProviderStreamOptions, +): AssistantMessageEventStream { + const provider = resolveApiProvider(model.api); + return provider.stream(model, context, options as StreamOptions); +} + +export async function complete<TApi extends Api>( + model: Model<TApi>, + context: Context, + options?: ProviderStreamOptions, +): Promise<AssistantMessage> { + const s = stream(model, context, options); + return s.result(); +} + +export function streamSimple<TApi extends Api>( + model: Model<TApi>, + context: Context, + options?: SimpleStreamOptions, +): AssistantMessageEventStream { + const provider = resolveApiProvider(model.api); + return provider.streamSimple(model, context, options); +} + +export async function completeSimple<TApi extends Api>( + model: Model<TApi>, + context: Context, + options?: SimpleStreamOptions, +): Promise<AssistantMessage> { + const s = streamSimple(model, context, options); + return s.result(); +} diff --git a/emain/ai/types.ts b/emain/ai/types.ts new file mode 100644 index 000000000..cf1ace60d --- /dev/null +++ b/emain/ai/types.ts @@ -0,0 +1,575 @@ +import type { AssistantMessageDiagnostic } from "./utils/diagnostics"; +import type { AssistantMessageEventStream } from "./utils/event-stream"; + +export type { AssistantMessageEventStream } from "./utils/event-stream"; + +export type KnownApi = + | "openai-completions" + | "mistral-conversations" + | "openai-responses" + | "azure-openai-responses" + | "openai-codex-responses" + | "anthropic-messages" + | "bedrock-converse-stream" + | "google-generative-ai" + | "google-vertex"; + +export type Api = KnownApi | (string & {}); + +export type KnownImagesApi = "openrouter-images"; + +export type ImagesApi = KnownImagesApi | (string & {}); + +export type KnownProvider = + | "amazon-bedrock" + | "anthropic" + | "google" + | "google-vertex" + | "openai" + | "azure-openai-responses" + | "openai-codex" + | "deepseek" + | "github-copilot" + | "xai" + | "groq" + | "cerebras" + | "openrouter" + | "vercel-ai-gateway" + | "zai" + | "mistral" + | "minimax" + | "minimax-cn" + | "moonshotai" + | "moonshotai-cn" + | "huggingface" + | "fireworks" + | "together" + | "opencode" + | "opencode-go" + | "kimi-coding" + | "cloudflare-workers-ai" + | "cloudflare-ai-gateway" + | "xiaomi" + | "xiaomi-token-plan-cn" + | "xiaomi-token-plan-ams" + | "xiaomi-token-plan-sgp"; +export type Provider = KnownProvider | string; + +export type KnownImagesProvider = "openrouter"; + +export type ImagesProvider = KnownImagesProvider | string; + +export type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh"; +export type ModelThinkingLevel = "off" | ThinkingLevel; +export type ThinkingLevelMap = Partial<Record<ModelThinkingLevel, string | null>>; + +/** Token budgets for each thinking level (token-based providers only) */ +export interface ThinkingBudgets { + minimal?: number; + low?: number; + medium?: number; + high?: number; +} + +// Base options all providers share +export type CacheRetention = "none" | "short" | "long"; + +export type Transport = "sse" | "websocket" | "websocket-cached" | "auto"; + +export interface ProviderResponse { + status: number; + headers: Record<string, string>; +} + +export interface StreamOptions { + temperature?: number; + maxTokens?: number; + signal?: AbortSignal; + apiKey?: string; + /** + * Preferred transport for providers that support multiple transports. + * Providers that do not support this option ignore it. + */ + transport?: Transport; + /** + * Prompt cache retention preference. Providers map this to their supported values. + * Default: "short". + */ + cacheRetention?: CacheRetention; + /** + * Optional session identifier for providers that support session-based caching. + * Providers can use this to enable prompt caching, request routing, or other + * session-aware features. Ignored by providers that don't support it. + */ + sessionId?: string; + /** + * Optional callback for inspecting or replacing provider payloads before sending. + * Return undefined to keep the payload unchanged. + */ + onPayload?: (payload: unknown, model: Model<Api>) => unknown | undefined | Promise<unknown | undefined>; + /** + * Optional callback invoked after an HTTP response is received and before + * its body stream is consumed. + */ + onResponse?: (response: ProviderResponse, model: Model<Api>) => void | Promise<void>; + /** + * Optional custom HTTP headers to include in API requests. + * Merged with provider defaults; can override default headers. + * Not supported by all providers (e.g., AWS Bedrock uses SDK auth). + */ + headers?: Record<string, string>; + /** + * HTTP request timeout in milliseconds for providers/SDKs that support it. + * For example, OpenAI and Anthropic SDK clients default to 10 minutes. + */ + timeoutMs?: number; + /** + * Maximum retry attempts for providers/SDKs that support client-side retries. + * For example, OpenAI and Anthropic SDK clients default to 2. + */ + maxRetries?: number; + /** + * Maximum delay in milliseconds to wait for a retry when the server requests a long wait. + * If the server's requested delay exceeds this value, the request fails immediately + * with an error containing the requested delay, allowing higher-level retry logic + * to handle it with user visibility. + * Default: 60000 (60 seconds). Set to 0 to disable the cap. + */ + maxRetryDelayMs?: number; + /** + * Optional metadata to include in API requests. + * Providers extract the fields they understand and ignore the rest. + * For example, Anthropic uses `user_id` for abuse tracking and rate limiting. + */ + metadata?: Record<string, unknown>; +} + +export type ProviderStreamOptions = StreamOptions & Record<string, unknown>; + +export interface ImagesOptions { + signal?: AbortSignal; + apiKey?: string; + /** + * Optional callback for inspecting or replacing provider payloads before sending. + * Return undefined to keep the payload unchanged. + */ + onPayload?: (payload: unknown, model: ImagesModel<ImagesApi>) => unknown | undefined | Promise<unknown | undefined>; + /** + * Optional callback invoked after an HTTP response is received. + */ + onResponse?: (response: ProviderResponse, model: ImagesModel<ImagesApi>) => void | Promise<void>; + /** + * Optional custom HTTP headers to include in API requests. + * Merged with provider defaults; can override default headers. + */ + headers?: Record<string, string>; + /** + * HTTP request timeout in milliseconds for providers/SDKs that support it. + */ + timeoutMs?: number; + /** + * Maximum retry attempts for providers/SDKs that support client-side retries. + */ + maxRetries?: number; + /** + * Maximum delay in milliseconds to wait for a retry when the server requests a long wait. + * If the server's requested delay exceeds this value, the request fails immediately + * with an error containing the requested delay, allowing higher-level retry logic + * to handle it with user visibility. + * Default: 60000 (60 seconds). Set to 0 to disable the cap. + */ + maxRetryDelayMs?: number; + /** + * Optional metadata to include in API requests. + * Providers extract the fields they understand and ignore the rest. + */ + metadata?: Record<string, unknown>; +} + +export type ProviderImagesOptions = ImagesOptions & Record<string, unknown>; + +// Unified options with reasoning passed to streamSimple() and completeSimple() +export interface SimpleStreamOptions extends StreamOptions { + reasoning?: ThinkingLevel; + /** Custom token budgets for thinking levels (token-based providers only) */ + thinkingBudgets?: ThinkingBudgets; +} + +// Generic StreamFunction with typed options. +// +// Contract: +// - Must return an AssistantMessageEventStream. +// - Once invoked, request/model/runtime failures should be encoded in the +// returned stream, not thrown. +// - Error termination must produce an AssistantMessage with stopReason +// "error" or "aborted" and errorMessage, emitted via the stream protocol. +export type StreamFunction<TApi extends Api = Api, TOptions extends StreamOptions = StreamOptions> = ( + model: Model<TApi>, + context: Context, + options?: TOptions, +) => AssistantMessageEventStream; + +export type ImagesFunction<TApi extends ImagesApi = ImagesApi, TOptions extends ImagesOptions = ImagesOptions> = ( + model: ImagesModel<TApi>, + context: ImagesContext, + options?: TOptions, +) => Promise<AssistantImages>; + +export interface TextSignatureV1 { + v: 1; + id: string; + phase?: "commentary" | "final_answer"; +} + +export interface TextContent { + type: "text"; + text: string; + textSignature?: string; // e.g., for OpenAI responses, message metadata (legacy id string or TextSignatureV1 JSON) +} + +export interface ThinkingContent { + type: "thinking"; + thinking: string; + thinkingSignature?: string; // e.g., for OpenAI responses, the reasoning item ID + /** When true, the thinking content was redacted by safety filters. The opaque + * encrypted payload is stored in `thinkingSignature` so it can be passed back + * to the API for multi-turn continuity. */ + redacted?: boolean; +} + +export interface ImageContent { + type: "image"; + data: string; // base64 encoded image data + mimeType: string; // e.g., "image/jpeg", "image/png" +} + +export interface ToolCall { + type: "toolCall"; + id: string; + name: string; + arguments: Record<string, any>; + thoughtSignature?: string; // Google-specific: opaque signature for reusing thought context +} + +export interface Usage { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + totalTokens: number; + cost: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + total: number; + }; +} + +export type StopReason = "stop" | "length" | "toolUse" | "error" | "aborted"; + +export interface UserMessage { + role: "user"; + content: string | (TextContent | ImageContent)[]; + timestamp: number; // Unix timestamp in milliseconds +} + +export interface AssistantMessage { + role: "assistant"; + content: (TextContent | ThinkingContent | ToolCall)[]; + api: Api; + provider: Provider; + model: string; + responseModel?: string; // Concrete `chunk.model` when different from the requested `model` (e.g. OpenRouter `auto` -> `anthropic/...`) + responseId?: string; // Provider-specific response/message identifier when the upstream API exposes one + diagnostics?: AssistantMessageDiagnostic[]; // Redacted provider/runtime diagnostics for failures and recoveries. + usage: Usage; + stopReason: StopReason; + errorMessage?: string; + timestamp: number; // Unix timestamp in milliseconds +} + +export interface ToolResultMessage<TDetails = any> { + role: "toolResult"; + toolCallId: string; + toolName: string; + content: (TextContent | ImageContent)[]; // Supports text and images + details?: TDetails; + isError: boolean; + timestamp: number; // Unix timestamp in milliseconds +} + +export type Message = UserMessage | AssistantMessage | ToolResultMessage; + +export type ImagesInputContent = TextContent | ImageContent; +export type ImagesOutputContent = TextContent | ImageContent; + +export interface ImagesContext { + input: ImagesInputContent[]; +} + +export type ImagesStopReason = "stop" | "error" | "aborted"; + +export interface AssistantImages { + api: ImagesApi; + provider: ImagesProvider; + model: string; + output: ImagesOutputContent[]; + responseId?: string; + usage?: Usage; + stopReason: ImagesStopReason; + errorMessage?: string; + timestamp: number; // Unix timestamp in milliseconds +} + +import type { TSchema } from "typebox"; + +export interface Tool<TParameters extends TSchema = TSchema> { + name: string; + description: string; + parameters: TParameters; +} + +export interface Context { + systemPrompt?: string; + messages: Message[]; + tools?: Tool[]; +} + +/** + * Event protocol for AssistantMessageEventStream. + * + * Streams should emit `start` before partial updates, then terminate with either: + * - `done` carrying the final successful AssistantMessage, or + * - `error` carrying the final AssistantMessage with stopReason "error" or "aborted" + * and errorMessage. + */ +export type AssistantMessageEvent = + | { type: "start"; partial: AssistantMessage } + | { type: "text_start"; contentIndex: number; partial: AssistantMessage } + | { type: "text_delta"; contentIndex: number; delta: string; partial: AssistantMessage } + | { type: "text_end"; contentIndex: number; content: string; partial: AssistantMessage } + | { type: "thinking_start"; contentIndex: number; partial: AssistantMessage } + | { type: "thinking_delta"; contentIndex: number; delta: string; partial: AssistantMessage } + | { type: "thinking_end"; contentIndex: number; content: string; partial: AssistantMessage } + | { type: "toolcall_start"; contentIndex: number; partial: AssistantMessage } + | { type: "toolcall_delta"; contentIndex: number; delta: string; partial: AssistantMessage } + | { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall; partial: AssistantMessage } + | { type: "done"; reason: Extract<StopReason, "stop" | "length" | "toolUse">; message: AssistantMessage } + | { type: "error"; reason: Extract<StopReason, "aborted" | "error">; error: AssistantMessage }; + +/** + * Compatibility settings for OpenAI-compatible completions APIs. + * Use this to override URL-based auto-detection for custom providers. + */ +export interface OpenAICompletionsCompat { + /** Whether the provider supports the `store` field. Default: auto-detected from URL. */ + supportsStore?: boolean; + /** Whether the provider supports the `developer` role (vs `system`). Default: auto-detected from URL. */ + supportsDeveloperRole?: boolean; + /** Whether the provider supports `reasoning_effort`. Default: auto-detected from URL. */ + supportsReasoningEffort?: boolean; + /** Whether the provider supports `stream_options: { include_usage: true }` for token usage in streaming responses. Default: true. */ + supportsUsageInStreaming?: boolean; + /** Which field to use for max tokens. Default: auto-detected from URL. */ + maxTokensField?: "max_completion_tokens" | "max_tokens"; + /** Whether tool results require the `name` field. Default: auto-detected from URL. */ + requiresToolResultName?: boolean; + /** Whether a user message after tool results requires an assistant message in between. Default: auto-detected from URL. */ + requiresAssistantAfterToolResult?: boolean; + /** Whether thinking blocks must be converted to text blocks with <thinking> delimiters. Default: auto-detected from URL. */ + requiresThinkingAsText?: boolean; + /** Whether all replayed assistant messages must include an empty reasoning_content field when reasoning is enabled. Default: auto-detected from URL. */ + requiresReasoningContentOnAssistantMessages?: boolean; + /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses top-level enable_thinking: boolean, "qwen" uses top-level enable_thinking: boolean, and "qwen-chat-template" uses chat_template_kwargs.enable_thinking. Default: "openai". */ + thinkingFormat?: "openai" | "openrouter" | "deepseek" | "together" | "zai" | "qwen" | "qwen-chat-template"; + /** OpenRouter-specific routing preferences. Only used when baseUrl points to OpenRouter. */ + openRouterRouting?: OpenRouterRouting; + /** Vercel AI Gateway routing preferences. Only used when baseUrl points to Vercel AI Gateway. */ + vercelGatewayRouting?: VercelGatewayRouting; + /** Whether z.ai supports top-level `tool_stream: true` for streaming tool call deltas. Default: false. */ + zaiToolStream?: boolean; + /** Whether the provider supports the `strict` field in tool definitions. Default: true. */ + supportsStrictMode?: boolean; + /** Cache control convention for prompt caching. "anthropic" applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user/assistant text content. */ + cacheControlFormat?: "anthropic"; + /** Whether to send known session-affinity headers (`session_id`, `x-client-request-id`, `x-session-affinity`) from `options.sessionId` when caching is enabled. Default: false. */ + sendSessionAffinityHeaders?: boolean; + /** Whether the provider supports long prompt cache retention (`prompt_cache_retention: "24h"` or Anthropic-style `cache_control.ttl: "1h"`, depending on format). Default: true. */ + supportsLongCacheRetention?: boolean; +} + +/** Compatibility settings for OpenAI Responses APIs. */ +export interface OpenAIResponsesCompat { + /** Whether to send the OpenAI `session_id` cache-affinity header from `options.sessionId` when caching is enabled. Default: true. */ + sendSessionIdHeader?: boolean; + /** Whether the provider supports `prompt_cache_retention: "24h"`. Default: true. */ + supportsLongCacheRetention?: boolean; +} + +/** Compatibility settings for Anthropic Messages-compatible APIs. */ +export interface AnthropicMessagesCompat { + /** + * Whether the provider accepts per-tool `eager_input_streaming`. + * When false, the Anthropic provider omits `tools[].eager_input_streaming` + * and sends the legacy `fine-grained-tool-streaming-2025-05-14` beta header + * for tool-enabled requests. + * Default: true. + */ + supportsEagerToolInputStreaming?: boolean; + /** Whether the provider supports Anthropic long cache retention (`cache_control.ttl: "1h"`). Default: true. */ + supportsLongCacheRetention?: boolean; + /** + * Whether to send the `x-session-affinity` header from `options.sessionId` + * when caching is enabled. Required for providers like Fireworks that use + * session affinity for prompt cache routing (requests to the same replica + * maximize cache hits). + * Default: false. + */ + sendSessionAffinityHeaders?: boolean; + /** + * Whether the provider supports Anthropic-style `cache_control` markers on + * tool definitions. When false, `cache_control` is omitted from tool params. + * Some Anthropic-compatible providers (e.g., Fireworks) do not support this + * field on tools and may reject or ignore it. + * Default: true. + */ + supportsCacheControlOnTools?: boolean; + /** + * Whether to force adaptive thinking (`thinking.type: "adaptive"` plus + * `output_config.effort`) regardless of the model id. Built-in models that + * require adaptive thinking set this in generated metadata. Custom + * Anthropic-compatible providers can set this to `true` for any model whose + * upstream requires the adaptive format. Set to `false` to + * opt out on overridden built-in models. + * Default: false. + */ + forceAdaptiveThinking?: boolean; +} + +/** + * OpenRouter provider routing preferences. + * Controls which upstream providers OpenRouter routes requests to. + * Sent as the `provider` field in the OpenRouter API request body. + * @see https://openrouter.ai/docs/guides/routing/provider-selection + */ +export interface OpenRouterRouting { + /** Whether to allow backup providers to serve requests. Default: true. */ + allow_fallbacks?: boolean; + /** Whether to filter providers to only those that support all parameters in the request. Default: false. */ + require_parameters?: boolean; + /** Data collection setting. "allow" (default): allow providers that may store/train on data. "deny": only use providers that don't collect user data. */ + data_collection?: "deny" | "allow"; + /** Whether to restrict routing to only ZDR (Zero Data Retention) endpoints. */ + zdr?: boolean; + /** Whether to restrict routing to only models that allow text distillation. */ + enforce_distillable_text?: boolean; + /** An ordered list of provider names/slugs to try in sequence, falling back to the next if unavailable. */ + order?: string[]; + /** List of provider names/slugs to exclusively allow for this request. */ + only?: string[]; + /** List of provider names/slugs to skip for this request. */ + ignore?: string[]; + /** A list of quantization levels to filter providers by (e.g., ["fp16", "bf16", "fp8", "fp6", "int8", "int4", "fp4", "fp32"]). */ + quantizations?: string[]; + /** Sorting strategy. Can be a string (e.g., "price", "throughput", "latency") or an object with `by` and `partition`. */ + sort?: + | string + | { + /** The sorting metric: "price", "throughput", "latency". */ + by?: string; + /** Partitioning strategy: "model" (default) or "none". */ + partition?: string | null; + }; + /** Maximum price per million tokens (USD). */ + max_price?: { + /** Price per million prompt tokens. */ + prompt?: number | string; + /** Price per million completion tokens. */ + completion?: number | string; + /** Price per image. */ + image?: number | string; + /** Price per audio unit. */ + audio?: number | string; + /** Price per request. */ + request?: number | string; + }; + /** Preferred minimum throughput (tokens/second). Can be a number (applies to p50) or an object with percentile-specific cutoffs. */ + preferred_min_throughput?: + | number + | { + /** Minimum tokens/second at the 50th percentile. */ + p50?: number; + /** Minimum tokens/second at the 75th percentile. */ + p75?: number; + /** Minimum tokens/second at the 90th percentile. */ + p90?: number; + /** Minimum tokens/second at the 99th percentile. */ + p99?: number; + }; + /** Preferred maximum latency (seconds). Can be a number (applies to p50) or an object with percentile-specific cutoffs. */ + preferred_max_latency?: + | number + | { + /** Maximum latency in seconds at the 50th percentile. */ + p50?: number; + /** Maximum latency in seconds at the 75th percentile. */ + p75?: number; + /** Maximum latency in seconds at the 90th percentile. */ + p90?: number; + /** Maximum latency in seconds at the 99th percentile. */ + p99?: number; + }; +} + +/** + * Vercel AI Gateway routing preferences. + * Controls which upstream providers the gateway routes requests to. + * @see https://vercel.com/docs/ai-gateway/models-and-providers/provider-options + */ +export interface VercelGatewayRouting { + /** List of provider slugs to exclusively use for this request (e.g., ["bedrock", "anthropic"]). */ + only?: string[]; + /** List of provider slugs to try in order (e.g., ["anthropic", "openai"]). */ + order?: string[]; +} + +// Model interface for the unified model system +export interface Model<TApi extends Api> { + id: string; + name: string; + api: TApi; + provider: Provider; + baseUrl: string; + reasoning: boolean; + /** + * Maps pi thinking levels to provider/model-specific values. + * Missing keys use provider defaults. null marks a level as unsupported. + */ + thinkingLevelMap?: ThinkingLevelMap; + input: ("text" | "image")[]; + cost: { + input: number; // $/million tokens + output: number; // $/million tokens + cacheRead: number; // $/million tokens + cacheWrite: number; // $/million tokens + }; + contextWindow: number; + maxTokens: number; + headers?: Record<string, string>; + /** Compatibility overrides for OpenAI-compatible APIs. If not set, auto-detected from baseUrl. */ + compat?: TApi extends "openai-completions" + ? OpenAICompletionsCompat + : TApi extends "openai-responses" + ? OpenAIResponsesCompat + : TApi extends "anthropic-messages" + ? AnthropicMessagesCompat + : never; +} + +export interface ImagesModel<TApi extends ImagesApi> + extends Omit<Model<Api>, "api" | "provider" | "reasoning" | "contextWindow" | "maxTokens" | "compat"> { + api: TApi; + provider: ImagesProvider; + output: ("text" | "image")[]; +} diff --git a/emain/ai/utils/diagnostics.ts b/emain/ai/utils/diagnostics.ts new file mode 100644 index 000000000..89d135619 --- /dev/null +++ b/emain/ai/utils/diagnostics.ts @@ -0,0 +1,45 @@ +export interface DiagnosticErrorInfo { + name?: string; + message: string; + stack?: string; + code?: string | number; +} + +export interface AssistantMessageDiagnostic { + type: string; + timestamp: number; + error?: DiagnosticErrorInfo; + details?: Record<string, unknown>; +} + +export function formatThrownValue(value: unknown): string { + if (value instanceof Error) return value.message || value.name; + if (typeof value === "string") return value; + return String(value); +} + +export function extractDiagnosticError(error: unknown): DiagnosticErrorInfo { + if (!(error instanceof Error)) return { name: "ThrownValue", message: formatThrownValue(error) }; + const code = (error as Error & { code?: unknown }).code; + return { + name: error.name || undefined, + message: error.message || error.name, + stack: error.stack, + code: typeof code === "string" || typeof code === "number" ? code : undefined, + }; +} + +export function createAssistantMessageDiagnostic( + type: string, + error: unknown, + details?: Record<string, unknown>, +): AssistantMessageDiagnostic { + return { type, timestamp: Date.now(), error: extractDiagnosticError(error), details }; +} + +export function appendAssistantMessageDiagnostic<T extends { diagnostics?: AssistantMessageDiagnostic[] }>( + message: T, + diagnostic: AssistantMessageDiagnostic, +): void { + message.diagnostics = [...(message.diagnostics ?? []), diagnostic]; +} diff --git a/emain/ai/utils/event-stream.ts b/emain/ai/utils/event-stream.ts new file mode 100644 index 000000000..30c49193b --- /dev/null +++ b/emain/ai/utils/event-stream.ts @@ -0,0 +1,88 @@ +import type { AssistantMessage, AssistantMessageEvent } from "../types"; + +// Generic event stream class for async iteration +export class EventStream<T, R = T> implements AsyncIterable<T> { + private queue: T[] = []; + private waiting: ((value: IteratorResult<T>) => void)[] = []; + private done = false; + private finalResultPromise: Promise<R>; + private resolveFinalResult!: (result: R) => void; + private isComplete: (event: T) => boolean; + private extractResult: (event: T) => R; + + constructor(isComplete: (event: T) => boolean, extractResult: (event: T) => R) { + this.isComplete = isComplete; + this.extractResult = extractResult; + this.finalResultPromise = new Promise((resolve) => { + this.resolveFinalResult = resolve; + }); + } + + push(event: T): void { + if (this.done) return; + + if (this.isComplete(event)) { + this.done = true; + this.resolveFinalResult(this.extractResult(event)); + } + + // Deliver to waiting consumer or queue it + const waiter = this.waiting.shift(); + if (waiter) { + waiter({ value: event, done: false }); + } else { + this.queue.push(event); + } + } + + end(result?: R): void { + this.done = true; + if (result !== undefined) { + this.resolveFinalResult(result); + } + // Notify all waiting consumers that we're done + while (this.waiting.length > 0) { + const waiter = this.waiting.shift()!; + waiter({ value: undefined as any, done: true }); + } + } + + async *[Symbol.asyncIterator](): AsyncIterator<T> { + while (true) { + if (this.queue.length > 0) { + yield this.queue.shift()!; + } else if (this.done) { + return; + } else { + const result = await new Promise<IteratorResult<T>>((resolve) => this.waiting.push(resolve)); + if (result.done) return; + yield result.value; + } + } + } + + result(): Promise<R> { + return this.finalResultPromise; + } +} + +export class AssistantMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> { + constructor() { + super( + (event) => event.type === "done" || event.type === "error", + (event) => { + if (event.type === "done") { + return event.message; + } else if (event.type === "error") { + return event.error; + } + throw new Error("Unexpected event type for final result"); + }, + ); + } +} + +/** Factory function for AssistantMessageEventStream (for use in extensions) */ +export function createAssistantMessageEventStream(): AssistantMessageEventStream { + return new AssistantMessageEventStream(); +} diff --git a/emain/ai/utils/hash.ts b/emain/ai/utils/hash.ts new file mode 100644 index 000000000..1ff55e8b4 --- /dev/null +++ b/emain/ai/utils/hash.ts @@ -0,0 +1,13 @@ +/** Fast deterministic hash to shorten long strings */ +export function shortHash(str: string): string { + let h1 = 0xdeadbeef; + let h2 = 0x41c6ce57; + for (let i = 0; i < str.length; i++) { + const ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); + return (h2 >>> 0).toString(36) + (h1 >>> 0).toString(36); +} diff --git a/emain/ai/utils/headers.ts b/emain/ai/utils/headers.ts new file mode 100644 index 000000000..fae8a0368 --- /dev/null +++ b/emain/ai/utils/headers.ts @@ -0,0 +1,7 @@ +export function headersToRecord(headers: Headers): Record<string, string> { + const result: Record<string, string> = {}; + for (const [key, value] of headers.entries()) { + result[key] = value; + } + return result; +} diff --git a/emain/ai/utils/json-parse.ts b/emain/ai/utils/json-parse.ts new file mode 100644 index 000000000..f39f9f09d --- /dev/null +++ b/emain/ai/utils/json-parse.ts @@ -0,0 +1,124 @@ +import { parse as partialParse } from "partial-json"; + +const VALID_JSON_ESCAPES = new Set(['"', "\\", "/", "b", "f", "n", "r", "t", "u"]); + +function isControlCharacter(char: string): boolean { + const codePoint = char.codePointAt(0); + return codePoint !== undefined && codePoint >= 0x00 && codePoint <= 0x1f; +} + +function escapeControlCharacter(char: string): string { + switch (char) { + case "\b": + return "\\b"; + case "\f": + return "\\f"; + case "\n": + return "\\n"; + case "\r": + return "\\r"; + case "\t": + return "\\t"; + default: + return `\\u${char.codePointAt(0)?.toString(16).padStart(4, "0") ?? "0000"}`; + } +} + +/** + * Repairs malformed JSON string literals by: + * - escaping raw control characters inside strings + * - doubling backslashes before invalid escape characters + */ +export function repairJson(json: string): string { + let repaired = ""; + let inString = false; + + for (let index = 0; index < json.length; index++) { + const char = json[index]; + + if (!inString) { + repaired += char; + if (char === '"') { + inString = true; + } + continue; + } + + if (char === '"') { + repaired += char; + inString = false; + continue; + } + + if (char === "\\") { + const nextChar = json[index + 1]; + if (nextChar === undefined) { + repaired += "\\\\"; + continue; + } + + if (nextChar === "u") { + const unicodeDigits = json.slice(index + 2, index + 6); + if (/^[0-9a-fA-F]{4}$/.test(unicodeDigits)) { + repaired += `\\u${unicodeDigits}`; + index += 5; + continue; + } + } + + if (VALID_JSON_ESCAPES.has(nextChar)) { + repaired += `\\${nextChar}`; + index += 1; + continue; + } + + repaired += "\\\\"; + continue; + } + + repaired += isControlCharacter(char) ? escapeControlCharacter(char) : char; + } + + return repaired; +} + +export function parseJsonWithRepair<T>(json: string): T { + try { + return JSON.parse(json) as T; + } catch (error) { + const repairedJson = repairJson(json); + if (repairedJson !== json) { + return JSON.parse(repairedJson) as T; + } + throw error; + } +} + +/** + * Attempts to parse potentially incomplete JSON during streaming. + * Always returns a valid object, even if the JSON is incomplete. + * + * @param partialJson The partial JSON string from streaming + * @returns Parsed object or empty object if parsing fails + */ +export function parseStreamingJson<T = Record<string, unknown>>(partialJson: string | undefined): T { + if (!partialJson || partialJson.trim() === "") { + return {} as T; + } + + try { + return parseJsonWithRepair<T>(partialJson); + } catch { + try { + const result = partialParse(partialJson); + return (result ?? {}) as T; + } catch { + try { + const result = partialParse(repairJson(partialJson)); + return (result ?? {}) as T; + } catch { + return {} as T; + } + } + } +} diff --git a/emain/ai/utils/node-http-proxy.ts b/emain/ai/utils/node-http-proxy.ts new file mode 100644 index 000000000..7b5f4750a --- /dev/null +++ b/emain/ai/utils/node-http-proxy.ts @@ -0,0 +1,123 @@ +import type { Agent as HttpAgent } from "node:http"; +import type { Agent as HttpsAgent } from "node:https"; +import { HttpProxyAgent } from "http-proxy-agent"; +import { HttpsProxyAgent } from "https-proxy-agent"; + +const DEFAULT_PROXY_PORTS: Record<string, number> = { + ftp: 21, + gopher: 70, + http: 80, + https: 443, + ws: 80, + wss: 443, +}; + +export interface NodeHttpProxyAgents { + httpAgent: HttpAgent; + httpsAgent: HttpsAgent; +} + +export const UNSUPPORTED_PROXY_PROTOCOL_MESSAGE = + "Unsupported proxy protocol. SOCKS and PAC proxy URLs are not supported; use an HTTP or HTTPS proxy URL."; + +function getProxyEnv(key: string): string { + return process.env[key.toLowerCase()] || process.env[key.toUpperCase()] || ""; +} + +function parseProxyTargetUrl(targetUrl: string | URL): URL | undefined { + if (targetUrl instanceof URL) { + return targetUrl; + } + + try { + return new URL(targetUrl); + } catch { + return undefined; + } +} + +function shouldProxyHostname(hostname: string, port: number): boolean { + const noProxy = getProxyEnv("no_proxy").toLowerCase(); + if (!noProxy) { + return true; + } + if (noProxy === "*") { + return false; + } + + return noProxy.split(/[,\s]/).every((proxy) => { + if (!proxy) { + return true; + } + + const parsedProxy = proxy.match(/^(.+):(\d+)$/); + let proxyHostname = parsedProxy ? parsedProxy[1] : proxy; + const proxyPort = parsedProxy ? Number.parseInt(parsedProxy[2]!, 10) : 0; + if (proxyPort && proxyPort !== port) { + return true; + } + + if (!/^[.*]/.test(proxyHostname)) { + return hostname !== proxyHostname; + } + + if (proxyHostname.startsWith("*")) { + proxyHostname = proxyHostname.slice(1); + } + return !hostname.endsWith(proxyHostname); + }); +} + +function getProxyForUrl(targetUrl: string | URL): string { + const parsedUrl = parseProxyTargetUrl(targetUrl); + if (!parsedUrl?.protocol || !parsedUrl.host) { + return ""; + } + + const protocol = parsedUrl.protocol.split(":", 1)[0]!; + const hostname = parsedUrl.host.replace(/:\d*$/, ""); + const port = Number.parseInt(parsedUrl.port, 10) || DEFAULT_PROXY_PORTS[protocol] || 0; + if (!shouldProxyHostname(hostname, port)) { + return ""; + } + + let proxy = getProxyEnv(`${protocol}_proxy`) || getProxyEnv("all_proxy"); + if (proxy && !proxy.includes("://")) { + proxy = `${protocol}://${proxy}`; + } + return proxy; +} + +export function resolveHttpProxyUrlForTarget(targetUrl: string | URL): URL | undefined { + const proxy = getProxyForUrl(targetUrl); + if (!proxy) { + return undefined; + } + + let proxyUrl: URL; + try { + proxyUrl = new URL(proxy); + } catch (error) { + throw new Error( + `Invalid proxy URL ${JSON.stringify(proxy)}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if (proxyUrl.protocol !== "http:" && proxyUrl.protocol !== "https:") { + throw new Error(`${UNSUPPORTED_PROXY_PROTOCOL_MESSAGE} Got ${proxyUrl.protocol}`); + } + + return proxyUrl; +} + +export function createHttpProxyAgentsForTarget(targetUrl: string | URL): NodeHttpProxyAgents | undefined { + const proxyUrl = resolveHttpProxyUrlForTarget(targetUrl); + if (!proxyUrl) { + return undefined; + } + + return { + httpAgent: new HttpProxyAgent(proxyUrl), + httpsAgent: new HttpsProxyAgent(proxyUrl) as unknown as HttpsAgent, + }; +} diff --git a/emain/ai/utils/oauth/anthropic.ts b/emain/ai/utils/oauth/anthropic.ts new file mode 100644 index 000000000..4763ac634 --- /dev/null +++ b/emain/ai/utils/oauth/anthropic.ts @@ -0,0 +1,402 @@ +/** + * Anthropic OAuth flow (Claude Pro/Max) + * + * NOTE: This module uses Node.js http.createServer for the OAuth callback server. + * It is only intended for CLI use, not browser environments. + */ + +import type { Server } from "node:http"; +import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page"; +import { generatePKCE } from "./pkce"; +import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types"; + +type CallbackServerInfo = { + server: Server; + redirectUri: string; + cancelWait: () => void; + waitForCode: () => Promise<{ code: string; state: string } | null>; +}; + +type NodeApis = { + createServer: typeof import("node:http").createServer; +}; + +let nodeApis: NodeApis | null = null; +let nodeApisPromise: Promise<NodeApis> | null = null; + +const decode = (s: string) => atob(s); +const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl"); +const AUTHORIZE_URL = "https://claude.ai/oauth/authorize"; +const TOKEN_URL = "https://platform.claude.com/v1/oauth/token"; +const CALLBACK_HOST = process.env.PI_OAUTH_CALLBACK_HOST || "127.0.0.1"; +const CALLBACK_PORT = 53692; +const CALLBACK_PATH = "/callback"; +const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`; +const SCOPES = + "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"; +async function getNodeApis(): Promise<NodeApis> { + if (nodeApis) return nodeApis; + if (!nodeApisPromise) { + if (typeof process === "undefined" || (!process.versions?.node && !process.versions?.bun)) { + throw new Error("Anthropic OAuth is only available in Node.js environments"); + } + nodeApisPromise = import("node:http").then((httpModule) => ({ + createServer: httpModule.createServer, + })); + } + nodeApis = await nodeApisPromise; + return nodeApis; +} + +function parseAuthorizationInput(input: string): { code?: string; state?: string } { + const value = input.trim(); + if (!value) return {}; + + try { + const url = new URL(value); + return { + code: url.searchParams.get("code") ?? undefined, + state: url.searchParams.get("state") ?? undefined, + }; + } catch { + // not a URL + } + + if (value.includes("#")) { + const [code, state] = value.split("#", 2); + return { code, state }; + } + + if (value.includes("code=")) { + const params = new URLSearchParams(value); + return { + code: params.get("code") ?? undefined, + state: params.get("state") ?? undefined, + }; + } + + return { code: value }; +} + +function formatErrorDetails(error: unknown): string { + if (error instanceof Error) { + const details: string[] = [`${error.name}: ${error.message}`]; + const errorWithCode = error as Error & { code?: string; errno?: number | string; cause?: unknown }; + if (errorWithCode.code) details.push(`code=${errorWithCode.code}`); + if (typeof errorWithCode.errno !== "undefined") details.push(`errno=${String(errorWithCode.errno)}`); + if (typeof error.cause !== "undefined") { + details.push(`cause=${formatErrorDetails(error.cause)}`); + } + if (error.stack) { + details.push(`stack=${error.stack}`); + } + return details.join("; "); + } + return String(error); +} + +async function startCallbackServer(expectedState: string): Promise<CallbackServerInfo> { + const { createServer } = await getNodeApis(); + + return new Promise((resolve, reject) => { + let settleWait: ((value: { code: string; state: string } | null) => void) | undefined; + const waitForCodePromise = new Promise<{ code: string; state: string } | null>((resolveWait) => { + let settled = false; + settleWait = (value) => { + if (settled) return; + settled = true; + resolveWait(value); + }; + }); + + const server = createServer((req, res) => { + try { + const url = new URL(req.url || "", "http://localhost"); + if (url.pathname !== CALLBACK_PATH) { + res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" }); + res.end(oauthErrorHtml("Callback route not found.")); + return; + } + + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const error = url.searchParams.get("error"); + + if (error) { + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); + res.end(oauthErrorHtml("Anthropic authentication did not complete.", `Error: ${error}`)); + return; + } + + if (!code || !state) { + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); + res.end(oauthErrorHtml("Missing code or state parameter.")); + return; + } + + if (state !== expectedState) { + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); + res.end(oauthErrorHtml("State mismatch.")); + return; + } + + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(oauthSuccessHtml("Anthropic authentication completed. You can close this window.")); + settleWait?.({ code, state }); + } catch { + res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("Internal error"); + } + }); + + server.on("error", (err) => { + reject(err); + }); + + server.listen(CALLBACK_PORT, CALLBACK_HOST, () => { + resolve({ + server, + redirectUri: REDIRECT_URI, + cancelWait: () => { + settleWait?.(null); + }, + waitForCode: () => waitForCodePromise, + }); + }); + }); +} + +async function postJson(url: string, body: Record<string, string | number>): Promise<string> { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(30_000), + }); + + const responseBody = await response.text(); + + if (!response.ok) { + throw new Error(`HTTP request failed. status=${response.status}; url=${url}; body=${responseBody}`); + } + + return responseBody; +} + +async function exchangeAuthorizationCode( + code: string, + state: string, + verifier: string, + redirectUri: string, +): Promise<OAuthCredentials> { + let responseBody: string; + try { + responseBody = await postJson(TOKEN_URL, { + grant_type: "authorization_code", + client_id: CLIENT_ID, + code, + state, + redirect_uri: redirectUri, + code_verifier: verifier, + }); + } catch (error) { + throw new Error( + `Token exchange request failed. url=${TOKEN_URL}; redirect_uri=${redirectUri}; response_type=authorization_code; details=${formatErrorDetails(error)}`, + ); + } + + let tokenData: { access_token: string; refresh_token: string; expires_in: number }; + try { + tokenData = JSON.parse(responseBody) as { access_token: string; refresh_token: string; expires_in: number }; + } catch (error) { + throw new Error( + `Token exchange returned invalid JSON. url=${TOKEN_URL}; body=${responseBody}; details=${formatErrorDetails(error)}`, + ); + } + + return { + refresh: tokenData.refresh_token, + access: tokenData.access_token, + expires: Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000, + }; +} + +/** + * Login with Anthropic OAuth (authorization code + PKCE) + */ +export async function loginAnthropic(options: { + onAuth: (info: { url: string; instructions?: string }) => void; + onPrompt: (prompt: OAuthPrompt) => Promise<string>; + onProgress?: (message: string) => void; + onManualCodeInput?: () => Promise<string>; +}): Promise<OAuthCredentials> { + const { verifier, challenge } = await generatePKCE(); + const server = await startCallbackServer(verifier); + + let code: string | undefined; + let state: string | undefined; + let redirectUriForExchange = REDIRECT_URI; + + try { + const authParams = new URLSearchParams({ + code: "true", + client_id: CLIENT_ID, + response_type: "code", + redirect_uri: REDIRECT_URI, + scope: SCOPES, + code_challenge: challenge, + code_challenge_method: "S256", + state: verifier, + }); + + options.onAuth({ + url: `${AUTHORIZE_URL}?${authParams.toString()}`, + instructions: + "Complete login in your browser. If the browser is on another machine, paste the final redirect URL here.", + }); + + if (options.onManualCodeInput) { + let manualInput: string | undefined; + let manualError: Error | undefined; + const manualPromise = options + .onManualCodeInput() + .then((input) => { + manualInput = input; + server.cancelWait(); + }) + .catch((err) => { + manualError = err instanceof Error ? err : new Error(String(err)); + server.cancelWait(); + }); + + const result = await server.waitForCode(); + + if (manualError) { + throw manualError; + } + + if (result?.code) { + code = result.code; + state = result.state; + redirectUriForExchange = REDIRECT_URI; + } else if (manualInput) { + const parsed = parseAuthorizationInput(manualInput); + if (parsed.state && parsed.state !== verifier) { + throw new Error("OAuth state mismatch"); + } + code = parsed.code; + state = parsed.state ?? verifier; + } + + if (!code) { + await manualPromise; + if (manualError) { + throw manualError; + } + if (manualInput) { + const parsed = parseAuthorizationInput(manualInput); + if (parsed.state && parsed.state !== verifier) { + throw new Error("OAuth state mismatch"); + } + code = parsed.code; + state = parsed.state ?? verifier; + } + } + } else { + const result = await server.waitForCode(); + if (result?.code) { + code = result.code; + state = result.state; + redirectUriForExchange = REDIRECT_URI; + } + } + + if (!code) { + const input = await options.onPrompt({ + message: "Paste the authorization code or full redirect URL:", + placeholder: REDIRECT_URI, + }); + const parsed = parseAuthorizationInput(input); + if (parsed.state && parsed.state !== verifier) { + throw new Error("OAuth state mismatch"); + } + code = parsed.code; + state = parsed.state ?? verifier; + } + + if (!code) { + throw new Error("Missing authorization code"); + } + + if (!state) { + throw new Error("Missing OAuth state"); + } + + options.onProgress?.("Exchanging authorization code for tokens..."); + return exchangeAuthorizationCode(code, state, verifier, redirectUriForExchange); + } finally { + server.server.close(); + } +} + +/** + * Refresh Anthropic OAuth token + */ +export async function refreshAnthropicToken(refreshToken: string): Promise<OAuthCredentials> { + let responseBody: string; + try { + responseBody = await postJson(TOKEN_URL, { + grant_type: "refresh_token", + client_id: CLIENT_ID, + refresh_token: refreshToken, + }); + } catch (error) { + throw new Error(`Anthropic token refresh request failed. url=${TOKEN_URL}; details=${formatErrorDetails(error)}`); + } + + let data: { access_token: string; refresh_token: string; expires_in: number; scope?: string }; + try { + data = JSON.parse(responseBody) as { + access_token: string; + refresh_token: string; + expires_in: number; + scope?: string; + }; + } catch (error) { + throw new Error( + `Anthropic token refresh returned invalid JSON. url=${TOKEN_URL}; body=${responseBody}; details=${formatErrorDetails(error)}`, + ); + } + + return { + refresh: data.refresh_token, + access: data.access_token, + expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, + }; +} + +export const anthropicOAuthProvider: OAuthProviderInterface = { + id: "anthropic", + name: "Anthropic (Claude Pro/Max)", + usesCallbackServer: true, + + async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> { + return loginAnthropic({ + onAuth: callbacks.onAuth, + onPrompt: callbacks.onPrompt, + onProgress: callbacks.onProgress, + onManualCodeInput: callbacks.onManualCodeInput, + }); + }, + + async refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> { + return refreshAnthropicToken(credentials.refresh); + }, + + getApiKey(credentials: OAuthCredentials): string { + return credentials.access; + }, +}; diff --git a/emain/ai/utils/oauth/device-code.ts b/emain/ai/utils/oauth/device-code.ts new file mode 100644 index 000000000..95dba2e78 --- /dev/null +++ b/emain/ai/utils/oauth/device-code.ts @@ -0,0 +1,80 @@ +const CANCEL_MESSAGE = "Login cancelled"; +const TIMEOUT_MESSAGE = "Device flow timed out"; +const SLOW_DOWN_TIMEOUT_MESSAGE = + "Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again."; +const MINIMUM_INTERVAL_MS = 1000; +// RFC 8628 section 3.2: if the authorization server omits `interval`, the client must use 5 seconds. +const DEFAULT_POLL_INTERVAL_SECONDS = 5; +// RFC 8628 section 3.5: `slow_down` means the polling interval must increase by 5 seconds. +const SLOW_DOWN_INTERVAL_INCREMENT_MS = 5000; + +export type OAuthDeviceCodePollResult = + | { status: "pending" } + | { status: "slow_down" } + | { status: "complete"; accessToken: string } + | { status: "failed"; message: string }; + +export type OAuthDeviceCodePollOptions = { + intervalSeconds?: number; + expiresInSeconds?: number; + poll: () => Promise<OAuthDeviceCodePollResult>; + signal?: AbortSignal; +}; + +function abortableSleep(ms: number, signal: AbortSignal | undefined, cancelMessage: string): Promise<void> { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error(cancelMessage)); + return; + } + + const onAbort = () => { + clearTimeout(timeout); + reject(new Error(cancelMessage)); + }; + const timeout = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +export async function pollOAuthDeviceCodeFlow(options: OAuthDeviceCodePollOptions): Promise<string> { + const deadline = + typeof options.expiresInSeconds === "number" + ? Date.now() + options.expiresInSeconds * 1000 + : Number.POSITIVE_INFINITY; + let intervalMs = Math.max( + MINIMUM_INTERVAL_MS, + Math.floor((options.intervalSeconds ?? DEFAULT_POLL_INTERVAL_SECONDS) * 1000), + ); + + let slowDownResponses = 0; + while (Date.now() < deadline) { + if (options.signal?.aborted) { + throw new Error(CANCEL_MESSAGE); + } + + const remainingMs = deadline - Date.now(); + await abortableSleep(Math.min(intervalMs, remainingMs), options.signal, CANCEL_MESSAGE); + + const result = await options.poll(); + if (result.status === "complete") { + return result.accessToken; + } + if (result.status === "pending") { + continue; + } + if (result.status === "slow_down") { + slowDownResponses += 1; + // RFC 8628 section 3.5: apply this increase to this and all subsequent requests. + intervalMs = Math.max(MINIMUM_INTERVAL_MS, intervalMs + SLOW_DOWN_INTERVAL_INCREMENT_MS); + continue; + } + throw new Error(result.message); + } + + throw new Error(slowDownResponses > 0 ? SLOW_DOWN_TIMEOUT_MESSAGE : TIMEOUT_MESSAGE); +} diff --git a/emain/ai/utils/oauth/github-copilot.ts b/emain/ai/utils/oauth/github-copilot.ts new file mode 100644 index 000000000..2291d551f --- /dev/null +++ b/emain/ai/utils/oauth/github-copilot.ts @@ -0,0 +1,345 @@ +/** + * GitHub Copilot OAuth flow + */ + +import { getModels } from "../../models"; +import type { Api, Model } from "../../types"; +import { pollOAuthDeviceCodeFlow } from "./device-code"; +import type { OAuthCredentials, OAuthDeviceCodeInfo, OAuthLoginCallbacks, OAuthProviderInterface } from "./types"; + +type CopilotCredentials = OAuthCredentials & { + enterpriseUrl?: string; +}; + +const decode = (s: string) => atob(s); +const CLIENT_ID = decode("SXYxLmI1MDdhMDhjODdlY2ZlOTg="); + +const COPILOT_HEADERS = { + "User-Agent": "GitHubCopilotChat/0.35.0", + "Editor-Version": "vscode/1.107.0", + "Editor-Plugin-Version": "copilot-chat/0.35.0", + "Copilot-Integration-Id": "vscode-chat", +} as const; + +type DeviceCodeResponse = { + device_code: string; + user_code: string; + verification_uri: string; + interval?: number; + expires_in: number; +}; + +type DeviceTokenSuccessResponse = { + access_token: string; + token_type?: string; + scope?: string; +}; + +type DeviceTokenErrorResponse = { + error: string; + error_description?: string; +}; + +export function normalizeDomain(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) return null; + try { + const url = trimmed.includes("://") ? new URL(trimmed) : new URL(`https://${trimmed}`); + return url.hostname; + } catch { + return null; + } +} + +function getUrls(domain: string): { + deviceCodeUrl: string; + accessTokenUrl: string; + copilotTokenUrl: string; +} { + return { + deviceCodeUrl: `https://${domain}/login/device/code`, + accessTokenUrl: `https://${domain}/login/oauth/access_token`, + copilotTokenUrl: `https://api.${domain}/copilot_internal/v2/token`, + }; +} + +/** + * Parse the proxy-ep from a Copilot token and convert to API base URL. + * Token format: tid=...;exp=...;proxy-ep=proxy.individual.githubcopilot.com;... + * Returns API URL like https://api.individual.githubcopilot.com + */ +function getBaseUrlFromToken(token: string): string | null { + const match = token.match(/proxy-ep=([^;]+)/); + if (!match) return null; + const proxyHost = match[1]; + // Convert proxy.xxx to api.xxx + const apiHost = proxyHost.replace(/^proxy\./, "api."); + return `https://${apiHost}`; +} + +export function getGitHubCopilotBaseUrl(token?: string, enterpriseDomain?: string): string { + // If we have a token, extract the base URL from proxy-ep + if (token) { + const urlFromToken = getBaseUrlFromToken(token); + if (urlFromToken) return urlFromToken; + } + // Fallback for enterprise or if token parsing fails + if (enterpriseDomain) return `https://copilot-api.${enterpriseDomain}`; + return "https://api.individual.githubcopilot.com"; +} + +async function fetchJson(url: string, init: RequestInit): Promise<unknown> { + const response = await fetch(url, init); + if (!response.ok) { + const text = await response.text(); + throw new Error(`${response.status} ${response.statusText}: ${text}`); + } + return response.json(); +} + +async function startDeviceFlow(domain: string): Promise<DeviceCodeResponse> { + const urls = getUrls(domain); + const data = await fetchJson(urls.deviceCodeUrl, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "GitHubCopilotChat/0.35.0", + }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + scope: "read:user", + }), + }); + + if (!data || typeof data !== "object") { + throw new Error("Invalid device code response"); + } + + const deviceCode = (data as Record<string, unknown>).device_code; + const userCode = (data as Record<string, unknown>).user_code; + const verificationUri = (data as Record<string, unknown>).verification_uri; + const interval = (data as Record<string, unknown>).interval; + const expiresIn = (data as Record<string, unknown>).expires_in; + + if ( + typeof deviceCode !== "string" || + typeof userCode !== "string" || + typeof verificationUri !== "string" || + (interval !== undefined && typeof interval !== "number") || + typeof expiresIn !== "number" + ) { + throw new Error("Invalid device code response fields"); + } + + return { + device_code: deviceCode as string, + user_code: userCode as string, + verification_uri: verificationUri as string, + interval: interval as number, + expires_in: expiresIn as number, + }; +} + +async function pollForGitHubAccessToken(domain: string, device: DeviceCodeResponse, signal?: AbortSignal) { + const urls = getUrls(domain); + return pollOAuthDeviceCodeFlow({ + intervalSeconds: device.interval, + expiresInSeconds: device.expires_in, + signal, + poll: async () => { + const raw = await fetchJson(urls.accessTokenUrl, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "GitHubCopilotChat/0.35.0", + }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + device_code: device.device_code, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); + + if (raw && typeof raw === "object" && typeof (raw as DeviceTokenSuccessResponse).access_token === "string") { + return { status: "complete", accessToken: (raw as DeviceTokenSuccessResponse).access_token }; + } + + if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") { + const { error, error_description: description } = raw as DeviceTokenErrorResponse; + if (error === "authorization_pending") { + return { status: "pending" }; + } + + if (error === "slow_down") { + return { status: "slow_down" }; + } + + const descriptionSuffix = description ? `: ${description}` : ""; + return { status: "failed", message: `Device flow failed: ${error}${descriptionSuffix}` }; + } + + return { status: "failed", message: "Invalid device token response" }; + }, + }); +} + +/** + * Refresh GitHub Copilot token + */ +export async function refreshGitHubCopilotToken( + refreshToken: string, + enterpriseDomain?: string, +): Promise<OAuthCredentials> { + const domain = enterpriseDomain || "github.com"; + const urls = getUrls(domain); + + const raw = await fetchJson(urls.copilotTokenUrl, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${refreshToken}`, + ...COPILOT_HEADERS, + }, + }); + + if (!raw || typeof raw !== "object") { + throw new Error("Invalid Copilot token response"); + } + + const token = (raw as Record<string, unknown>).token; + const expiresAt = (raw as Record<string, unknown>).expires_at; + + if (typeof token !== "string" || typeof expiresAt !== "number") { + throw new Error("Invalid Copilot token response fields"); + } + + return { + refresh: refreshToken, + access: token, + expires: expiresAt * 1000 - 5 * 60 * 1000, + enterpriseUrl: enterpriseDomain, + }; +} + +/** + * Enable a model for the user's GitHub Copilot account. + * This is required for some models (like Claude, Grok) before they can be used. + */ +async function enableGitHubCopilotModel(token: string, modelId: string, enterpriseDomain?: string): Promise<boolean> { + const baseUrl = getGitHubCopilotBaseUrl(token, enterpriseDomain); + const url = `${baseUrl}/models/${modelId}/policy`; + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + ...COPILOT_HEADERS, + "openai-intent": "chat-policy", + "x-interaction-type": "chat-policy", + }, + body: JSON.stringify({ state: "enabled" }), + }); + return response.ok; + } catch { + return false; + } +} + +/** + * Enable all known GitHub Copilot models that may require policy acceptance. + * Called after successful login to ensure all models are available. + */ +async function enableAllGitHubCopilotModels( + token: string, + enterpriseDomain?: string, + onProgress?: (model: string, success: boolean) => void, +): Promise<void> { + const models = getModels("github-copilot"); + await Promise.all( + models.map(async (model) => { + const success = await enableGitHubCopilotModel(token, model.id, enterpriseDomain); + onProgress?.(model.id, success); + }), + ); +} + +/** + * Login with GitHub Copilot OAuth (device code flow) + * + * @param options.onDeviceCode - Callback with URL and user code + * @param options.onPrompt - Callback to prompt user for input + * @param options.onProgress - Optional progress callback + * @param options.signal - Optional AbortSignal for cancellation + */ +export async function loginGitHubCopilot(options: { + onDeviceCode: (info: OAuthDeviceCodeInfo) => void; + onPrompt: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise<string>; + onProgress?: (message: string) => void; + signal?: AbortSignal; +}): Promise<OAuthCredentials> { + const input = await options.onPrompt({ + message: "GitHub Enterprise URL/domain (blank for github.com)", + placeholder: "company.ghe.com", + allowEmpty: true, + }); + + if (options.signal?.aborted) { + throw new Error("Login cancelled"); + } + + const trimmed = input.trim(); + const enterpriseDomain = normalizeDomain(input); + if (trimmed && !enterpriseDomain) { + throw new Error("Invalid GitHub Enterprise URL/domain"); + } + const domain = enterpriseDomain || "github.com"; + + const device = await startDeviceFlow(domain); + options.onDeviceCode({ + userCode: device.user_code, + verificationUri: device.verification_uri, + intervalSeconds: device.interval, + expiresInSeconds: device.expires_in, + }); + + const githubAccessToken = await pollForGitHubAccessToken(domain, device, options.signal); + const credentials = await refreshGitHubCopilotToken(githubAccessToken, enterpriseDomain ?? undefined); + + // Enable all models after successful login + options.onProgress?.("Enabling models..."); + await enableAllGitHubCopilotModels(credentials.access, enterpriseDomain ?? undefined); + return credentials; +} + +export const githubCopilotOAuthProvider: OAuthProviderInterface = { + id: "github-copilot", + name: "GitHub Copilot", + + async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> { + return loginGitHubCopilot({ + onDeviceCode: callbacks.onDeviceCode, + onPrompt: callbacks.onPrompt, + onProgress: callbacks.onProgress, + signal: callbacks.signal, + }); + }, + + async refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> { + const creds = credentials as CopilotCredentials; + return refreshGitHubCopilotToken(creds.refresh, creds.enterpriseUrl); + }, + + getApiKey(credentials: OAuthCredentials): string { + return credentials.access; + }, + + modifyModels(models: Model<Api>[], credentials: OAuthCredentials): Model<Api>[] { + const creds = credentials as CopilotCredentials; + const domain = creds.enterpriseUrl ? (normalizeDomain(creds.enterpriseUrl) ?? undefined) : undefined; + const baseUrl = getGitHubCopilotBaseUrl(creds.access, domain); + return models.map((m) => (m.provider === "github-copilot" ? { ...m, baseUrl } : m)); + }, +}; diff --git a/emain/ai/utils/oauth/index.ts b/emain/ai/utils/oauth/index.ts new file mode 100644 index 000000000..60dfa808e --- /dev/null +++ b/emain/ai/utils/oauth/index.ts @@ -0,0 +1,153 @@ +/** + * OAuth credential management for AI providers. + * + * This module handles login, token refresh, and credential storage + * for OAuth-based providers: + * - Anthropic (Claude Pro/Max) + * - GitHub Copilot + */ + +// Anthropic +export { anthropicOAuthProvider, loginAnthropic, refreshAnthropicToken } from "./anthropic"; +export * from "./device-code"; +// GitHub Copilot +export { + getGitHubCopilotBaseUrl, + githubCopilotOAuthProvider, + loginGitHubCopilot, + normalizeDomain, + refreshGitHubCopilotToken, +} from "./github-copilot"; +// OpenAI Codex (ChatGPT OAuth) +export { loginOpenAICodex, openaiCodexOAuthProvider, refreshOpenAICodexToken } from "./openai-codex"; + +export * from "./types"; + +// ============================================================================ +// Provider Registry +// ============================================================================ + +import { anthropicOAuthProvider } from "./anthropic"; +import { githubCopilotOAuthProvider } from "./github-copilot"; +import { openaiCodexOAuthProvider } from "./openai-codex"; +import type { OAuthCredentials, OAuthProviderId, OAuthProviderInfo, OAuthProviderInterface } from "./types"; + +const BUILT_IN_OAUTH_PROVIDERS: OAuthProviderInterface[] = [ + anthropicOAuthProvider, + githubCopilotOAuthProvider, + openaiCodexOAuthProvider, +]; + +const oauthProviderRegistry = new Map<string, OAuthProviderInterface>( + BUILT_IN_OAUTH_PROVIDERS.map((provider) => [provider.id, provider]), +); + +/** + * Get an OAuth provider by ID + */ +export function getOAuthProvider(id: OAuthProviderId): OAuthProviderInterface | undefined { + return oauthProviderRegistry.get(id); +} + +/** + * Register a custom OAuth provider + */ +export function registerOAuthProvider(provider: OAuthProviderInterface): void { + oauthProviderRegistry.set(provider.id, provider); +} + +/** + * Unregister an OAuth provider. + * + * If the provider is built-in, restores the built-in implementation. + * Custom providers are removed completely. + */ +export function unregisterOAuthProvider(id: string): void { + const builtInProvider = BUILT_IN_OAUTH_PROVIDERS.find((provider) => provider.id === id); + if (builtInProvider) { + oauthProviderRegistry.set(id, builtInProvider); + return; + } + oauthProviderRegistry.delete(id); +} + +/** + * Reset OAuth providers to built-ins. + */ +export function resetOAuthProviders(): void { + oauthProviderRegistry.clear(); + for (const provider of BUILT_IN_OAUTH_PROVIDERS) { + oauthProviderRegistry.set(provider.id, provider); + } +} + +/** + * Get all registered OAuth providers + */ +export function getOAuthProviders(): OAuthProviderInterface[] { + return Array.from(oauthProviderRegistry.values()); +} + +/** + * @deprecated Use getOAuthProviders() which returns OAuthProviderInterface[] + */ +export function getOAuthProviderInfoList(): OAuthProviderInfo[] { + return getOAuthProviders().map((p) => ({ + id: p.id, + name: p.name, + available: true, + })); +} + +// ============================================================================ +// High-level API (uses provider registry) +// ============================================================================ + +/** + * Refresh token for any OAuth provider. + * @deprecated Use getOAuthProvider(id).refreshToken() instead + */ +export async function refreshOAuthToken( + providerId: OAuthProviderId, + credentials: OAuthCredentials, +): Promise<OAuthCredentials> { + const provider = getOAuthProvider(providerId); + if (!provider) { + throw new Error(`Unknown OAuth provider: ${providerId}`); + } + return provider.refreshToken(credentials); +} + +/** + * Get API key for a provider from OAuth credentials. + * Automatically refreshes expired tokens. + * + * @returns API key string and updated credentials, or null if no credentials + * @throws Error if refresh fails + */ +export async function getOAuthApiKey( + providerId: OAuthProviderId, + credentials: Record<string, OAuthCredentials>, +): Promise<{ newCredentials: OAuthCredentials; apiKey: string } | null> { + const provider = getOAuthProvider(providerId); + if (!provider) { + throw new Error(`Unknown OAuth provider: ${providerId}`); + } + + let creds = credentials[providerId]; + if (!creds) { + return null; + } + + // Refresh if expired + if (Date.now() >= creds.expires) { + try { + creds = await provider.refreshToken(creds); + } catch (_error) { + throw new Error(`Failed to refresh OAuth token for ${providerId}`); + } + } + + const apiKey = provider.getApiKey(creds); + return { newCredentials: creds, apiKey }; +} diff --git a/emain/ai/utils/oauth/oauth-page.ts b/emain/ai/utils/oauth/oauth-page.ts new file mode 100644 index 000000000..a2a1c4d8a --- /dev/null +++ b/emain/ai/utils/oauth/oauth-page.ts @@ -0,0 +1,109 @@ +const LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" aria-hidden="true"><path fill="#fff" fill-rule="evenodd" d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"/><path fill="#fff" d="M517.36 400 H634.72 V634.72 H517.36 Z"/></svg>`; + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function renderPage(options: { title: string; heading: string; message: string; details?: string }): string { + const title = escapeHtml(options.title); + const heading = escapeHtml(options.heading); + const message = escapeHtml(options.message); + const details = options.details ? escapeHtml(options.details) : undefined; + + return `<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>${title} + + + +
+ +

${heading}

+

${message}

+ ${details ? `
${details}
` : ""} +
+ +`; +} + +export function oauthSuccessHtml(message: string): string { + return renderPage({ + title: "Authentication successful", + heading: "Authentication successful", + message, + }); +} + +export function oauthErrorHtml(message: string, details?: string): string { + return renderPage({ + title: "Authentication failed", + heading: "Authentication failed", + message, + details, + }); +} diff --git a/emain/ai/utils/oauth/openai-codex.ts b/emain/ai/utils/oauth/openai-codex.ts new file mode 100644 index 000000000..1fc08b431 --- /dev/null +++ b/emain/ai/utils/oauth/openai-codex.ts @@ -0,0 +1,458 @@ +/** + * OpenAI Codex (ChatGPT OAuth) flow + * + * NOTE: This module uses Node.js crypto and http for the OAuth callback. + * It is only intended for CLI use, not browser environments. + */ + +// NEVER convert to top-level imports - breaks browser/Vite builds +let _randomBytes: typeof import("node:crypto").randomBytes | null = null; +let _http: typeof import("node:http") | null = null; +if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) { + import("node:crypto").then((m) => { + _randomBytes = m.randomBytes; + }); + import("node:http").then((m) => { + _http = m; + }); +} + +import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page"; +import { generatePKCE } from "./pkce"; +import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types"; + +const CALLBACK_HOST = process.env.PI_OAUTH_CALLBACK_HOST || "127.0.0.1"; +const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; +const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"; +const TOKEN_URL = "https://auth.openai.com/oauth/token"; +const REDIRECT_URI = "http://localhost:1455/auth/callback"; +const SCOPE = "openid profile email offline_access"; +const JWT_CLAIM_PATH = "https://api.openai.com/auth"; + +type TokenSuccess = { type: "success"; access: string; refresh: string; expires: number }; +type TokenFailure = { type: "failed"; message: string; status?: number }; +type TokenResult = TokenSuccess | TokenFailure; + +type JwtPayload = { + [JWT_CLAIM_PATH]?: { + chatgpt_account_id?: string; + }; + [key: string]: unknown; +}; + +function createState(): string { + if (!_randomBytes) { + throw new Error("OpenAI Codex OAuth is only available in Node.js environments"); + } + return _randomBytes(16).toString("hex"); +} + +function parseAuthorizationInput(input: string): { code?: string; state?: string } { + const value = input.trim(); + if (!value) return {}; + + try { + const url = new URL(value); + return { + code: url.searchParams.get("code") ?? undefined, + state: url.searchParams.get("state") ?? undefined, + }; + } catch { + // not a URL + } + + if (value.includes("#")) { + const [code, state] = value.split("#", 2); + return { code, state }; + } + + if (value.includes("code=")) { + const params = new URLSearchParams(value); + return { + code: params.get("code") ?? undefined, + state: params.get("state") ?? undefined, + }; + } + + return { code: value }; +} + +function decodeJwt(token: string): JwtPayload | null { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + const payload = parts[1] ?? ""; + const decoded = atob(payload); + return JSON.parse(decoded) as JwtPayload; + } catch { + return null; + } +} + +async function exchangeAuthorizationCode( + code: string, + verifier: string, + redirectUri: string = REDIRECT_URI, +): Promise { + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: CLIENT_ID, + code, + code_verifier: verifier, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + return { + type: "failed", + status: response.status, + message: `OpenAI Codex token exchange failed (${response.status}): ${text || response.statusText}`, + }; + } + + const json = (await response.json()) as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + }; + + if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") { + return { + type: "failed", + message: `OpenAI Codex token exchange response missing fields: ${JSON.stringify(json)}`, + }; + } + + return { + type: "success", + access: json.access_token, + refresh: json.refresh_token, + expires: Date.now() + json.expires_in * 1000, + }; +} + +async function refreshAccessToken(refreshToken: string): Promise { + try { + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: CLIENT_ID, + }), + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + return { + type: "failed", + status: response.status, + message: `OpenAI Codex token refresh failed (${response.status}): ${text || response.statusText}`, + }; + } + + const json = (await response.json()) as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + }; + + if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") { + return { + type: "failed", + message: `OpenAI Codex token refresh response missing fields: ${JSON.stringify(json)}`, + }; + } + + return { + type: "success", + access: json.access_token, + refresh: json.refresh_token, + expires: Date.now() + json.expires_in * 1000, + }; + } catch (error) { + return { + type: "failed", + message: `OpenAI Codex token refresh error: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +async function createAuthorizationFlow( + originator: string = "pi", +): Promise<{ verifier: string; state: string; url: string }> { + const { verifier, challenge } = await generatePKCE(); + const state = createState(); + + const url = new URL(AUTHORIZE_URL); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", CLIENT_ID); + url.searchParams.set("redirect_uri", REDIRECT_URI); + url.searchParams.set("scope", SCOPE); + url.searchParams.set("code_challenge", challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", state); + url.searchParams.set("id_token_add_organizations", "true"); + url.searchParams.set("codex_cli_simplified_flow", "true"); + url.searchParams.set("originator", originator); + + return { verifier, state, url: url.toString() }; +} + +type OAuthServerInfo = { + close: () => void; + cancelWait: () => void; + waitForCode: () => Promise<{ code: string } | null>; +}; + +function startLocalOAuthServer(state: string): Promise { + if (!_http) { + throw new Error("OpenAI Codex OAuth is only available in Node.js environments"); + } + + let settleWait: ((value: { code: string } | null) => void) | undefined; + const waitForCodePromise = new Promise<{ code: string } | null>((resolve) => { + let settled = false; + settleWait = (value) => { + if (settled) return; + settled = true; + resolve(value); + }; + }); + + const server = _http.createServer((req, res) => { + try { + const url = new URL(req.url || "", "http://localhost"); + if (url.pathname !== "/auth/callback") { + res.statusCode = 404; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(oauthErrorHtml("Callback route not found.")); + return; + } + if (url.searchParams.get("state") !== state) { + res.statusCode = 400; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(oauthErrorHtml("State mismatch.")); + return; + } + const code = url.searchParams.get("code"); + if (!code) { + res.statusCode = 400; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(oauthErrorHtml("Missing authorization code.")); + return; + } + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(oauthSuccessHtml("OpenAI authentication completed. You can close this window.")); + settleWait?.({ code }); + } catch { + res.statusCode = 500; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(oauthErrorHtml("Internal error while processing OAuth callback.")); + } + }); + + return new Promise((resolve) => { + server + .listen(1455, CALLBACK_HOST, () => { + resolve({ + close: () => server.close(), + cancelWait: () => { + settleWait?.(null); + }, + waitForCode: () => waitForCodePromise, + }); + }) + .on("error", (_err: NodeJS.ErrnoException) => { + settleWait?.(null); + resolve({ + close: () => { + try { + server.close(); + } catch { + // ignore + } + }, + cancelWait: () => {}, + waitForCode: async () => null, + }); + }); + }); +} + +function getAccountId(accessToken: string): string | null { + const payload = decodeJwt(accessToken); + const auth = payload?.[JWT_CLAIM_PATH]; + const accountId = auth?.chatgpt_account_id; + return typeof accountId === "string" && accountId.length > 0 ? accountId : null; +} + +/** + * Login with OpenAI Codex OAuth + * + * @param options.onAuth - Called with URL and instructions when auth starts + * @param options.onPrompt - Called to prompt user for manual code paste (fallback if no onManualCodeInput) + * @param options.onProgress - Optional progress messages + * @param options.onManualCodeInput - Optional promise that resolves with user-pasted code. + * Races with browser callback - whichever completes first wins. + * Useful for showing paste input immediately alongside browser flow. + * @param options.originator - OAuth originator parameter (defaults to "pi") + */ +export async function loginOpenAICodex(options: { + onAuth: (info: { url: string; instructions?: string }) => void; + onPrompt: (prompt: OAuthPrompt) => Promise; + onProgress?: (message: string) => void; + onManualCodeInput?: () => Promise; + originator?: string; +}): Promise { + const { verifier, state, url } = await createAuthorizationFlow(options.originator); + const server = await startLocalOAuthServer(state); + + options.onAuth({ url, instructions: "A browser window should open. Complete login to finish." }); + + let code: string | undefined; + try { + if (options.onManualCodeInput) { + // Race between browser callback and manual input + let manualCode: string | undefined; + let manualError: Error | undefined; + const manualPromise = options + .onManualCodeInput() + .then((input) => { + manualCode = input; + server.cancelWait(); + }) + .catch((err) => { + manualError = err instanceof Error ? err : new Error(String(err)); + server.cancelWait(); + }); + + const result = await server.waitForCode(); + + // If manual input was cancelled, throw that error + if (manualError) { + throw manualError; + } + + if (result?.code) { + // Browser callback won + code = result.code; + } else if (manualCode) { + // Manual input won (or callback timed out and user had entered code) + const parsed = parseAuthorizationInput(manualCode); + if (parsed.state && parsed.state !== state) { + throw new Error("State mismatch"); + } + code = parsed.code; + } + + // If still no code, wait for manual promise to complete and try that + if (!code) { + await manualPromise; + if (manualError) { + throw manualError; + } + if (manualCode) { + const parsed = parseAuthorizationInput(manualCode); + if (parsed.state && parsed.state !== state) { + throw new Error("State mismatch"); + } + code = parsed.code; + } + } + } else { + // Original flow: wait for callback, then prompt if needed + const result = await server.waitForCode(); + if (result?.code) { + code = result.code; + } + } + + // Fallback to onPrompt if still no code + if (!code) { + const input = await options.onPrompt({ + message: "Paste the authorization code (or full redirect URL):", + }); + const parsed = parseAuthorizationInput(input); + if (parsed.state && parsed.state !== state) { + throw new Error("State mismatch"); + } + code = parsed.code; + } + + if (!code) { + throw new Error("Missing authorization code"); + } + + const tokenResult = await exchangeAuthorizationCode(code, verifier); + if (tokenResult.type !== "success") { + throw new Error(tokenResult.message); + } + + const accountId = getAccountId(tokenResult.access); + if (!accountId) { + throw new Error("Failed to extract accountId from token"); + } + + return { + access: tokenResult.access, + refresh: tokenResult.refresh, + expires: tokenResult.expires, + accountId, + }; + } finally { + server.close(); + } +} + +/** + * Refresh OpenAI Codex OAuth token + */ +export async function refreshOpenAICodexToken(refreshToken: string): Promise { + const result = await refreshAccessToken(refreshToken); + if (result.type !== "success") { + throw new Error(result.message); + } + + const accountId = getAccountId(result.access); + if (!accountId) { + throw new Error("Failed to extract accountId from token"); + } + + return { + access: result.access, + refresh: result.refresh, + expires: result.expires, + accountId, + }; +} + +export const openaiCodexOAuthProvider: OAuthProviderInterface = { + id: "openai-codex", + name: "ChatGPT Plus/Pro (Codex Subscription)", + usesCallbackServer: true, + + async login(callbacks: OAuthLoginCallbacks): Promise { + return loginOpenAICodex({ + onAuth: callbacks.onAuth, + onPrompt: callbacks.onPrompt, + onProgress: callbacks.onProgress, + onManualCodeInput: callbacks.onManualCodeInput, + }); + }, + + async refreshToken(credentials: OAuthCredentials): Promise { + return refreshOpenAICodexToken(credentials.refresh); + }, + + getApiKey(credentials: OAuthCredentials): string { + return credentials.access; + }, +}; diff --git a/emain/ai/utils/oauth/pkce.ts b/emain/ai/utils/oauth/pkce.ts new file mode 100644 index 000000000..bf7ac7d58 --- /dev/null +++ b/emain/ai/utils/oauth/pkce.ts @@ -0,0 +1,34 @@ +/** + * PKCE utilities using Web Crypto API. + * Works in both Node.js 20+ and browsers. + */ + +/** + * Encode bytes as base64url string. + */ +function base64urlEncode(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); +} + +/** + * Generate PKCE code verifier and challenge. + * Uses Web Crypto API for cross-platform compatibility. + */ +export async function generatePKCE(): Promise<{ verifier: string; challenge: string }> { + // Generate random verifier + const verifierBytes = new Uint8Array(32); + crypto.getRandomValues(verifierBytes); + const verifier = base64urlEncode(verifierBytes); + + // Compute SHA-256 challenge + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const challenge = base64urlEncode(new Uint8Array(hashBuffer)); + + return { verifier, challenge }; +} diff --git a/emain/ai/utils/oauth/types.ts b/emain/ai/utils/oauth/types.ts new file mode 100644 index 000000000..d0d9031b0 --- /dev/null +++ b/emain/ai/utils/oauth/types.ts @@ -0,0 +1,79 @@ +import type { Api, Model } from "../../types"; + +export type OAuthCredentials = { + refresh: string; + access: string; + expires: number; + [key: string]: unknown; +}; + +export type OAuthProviderId = string; + +/** @deprecated Use OAuthProviderId instead */ +export type OAuthProvider = OAuthProviderId; + +export type OAuthPrompt = { + message: string; + placeholder?: string; + allowEmpty?: boolean; +}; + +export type OAuthAuthInfo = { + url: string; + instructions?: string; +}; + +export type OAuthDeviceCodeInfo = { + userCode: string; + verificationUri: string; + intervalSeconds?: number; + expiresInSeconds?: number; +}; + +export type OAuthSelectOption = { + id: string; + label: string; +}; + +export type OAuthSelectPrompt = { + message: string; + options: OAuthSelectOption[]; +}; + +export interface OAuthLoginCallbacks { + onAuth: (info: OAuthAuthInfo) => void; + onDeviceCode: (info: OAuthDeviceCodeInfo) => void; + onPrompt: (prompt: OAuthPrompt) => Promise; + onProgress?: (message: string) => void; + onManualCodeInput?: () => Promise; + /** Show an interactive selector and return the selected option id, or undefined on cancel. */ + onSelect: (prompt: OAuthSelectPrompt) => Promise; + signal?: AbortSignal; +} + +export interface OAuthProviderInterface { + readonly id: OAuthProviderId; + readonly name: string; + + /** Run the login flow, return credentials to persist */ + login(callbacks: OAuthLoginCallbacks): Promise; + + /** Whether login uses a local callback server and supports manual code input. */ + usesCallbackServer?: boolean; + + /** Refresh expired credentials, return updated credentials to persist */ + refreshToken(credentials: OAuthCredentials): Promise; + + /** Convert credentials to API key string for the provider */ + getApiKey(credentials: OAuthCredentials): string; + + /** Optional: modify models for this provider (e.g., update baseUrl) */ + modifyModels?(models: Model[], credentials: OAuthCredentials): Model[]; +} + +/** @deprecated Use OAuthProviderInterface instead */ +export interface OAuthProviderInfo { + id: OAuthProviderId; + name: string; + available: boolean; +} diff --git a/emain/ai/utils/overflow.ts b/emain/ai/utils/overflow.ts new file mode 100644 index 000000000..68f485e38 --- /dev/null +++ b/emain/ai/utils/overflow.ts @@ -0,0 +1,158 @@ +import type { AssistantMessage } from "../types"; + +/** + * Regex patterns to detect context overflow errors from different providers. + * + * These patterns match error messages returned when the input exceeds + * the model's context window. + * + * Provider-specific patterns (with example error messages): + * + * - Anthropic: "prompt is too long: 213462 tokens > 200000 maximum" + * - Anthropic: "413 {\"error\":{\"type\":\"request_too_large\",\"message\":\"Request exceeds the maximum size\"}}" + * - OpenAI: "Your input exceeds the context window of this model" + * - OpenAI/LiteLLM: "Requested token count exceeds the model's maximum context length of 131072 tokens" + * - Google: "The input token count (1196265) exceeds the maximum number of tokens allowed (1048575)" + * - xAI: "This model's maximum prompt length is 131072 but the request contains 537812 tokens" + * - Groq: "Please reduce the length of the messages or completion" + * - OpenRouter: "This endpoint's maximum context length is X tokens. However, you requested about Y tokens" + * - Together AI: "The input (X tokens) is longer than the model's context length (Y tokens)." + * - llama.cpp: "the request exceeds the available context size, try increasing it" + * - LM Studio: "tokens to keep from the initial prompt is greater than the context length" + * - GitHub Copilot: "prompt token count of X exceeds the limit of Y" + * - MiniMax: "invalid params, context window exceeds limit" + * - Kimi For Coding: "Your request exceeded model token limit: X (requested: Y)" + * - Cerebras: "400/413 status code (no body)" + * - Mistral: "Prompt contains X tokens ... too large for model with Y maximum context length" + * - z.ai: Does NOT error, accepts overflow silently - handled via usage.input > contextWindow + * - Xiaomi MiMo: Truncates input to fill contextWindow exactly, then returns finish_reason "length" + * with output=0 (no room left to generate). Detected via stopReason "length" + zero output + + * input filling the context window. + * - Ollama: Some deployments truncate silently, others return errors like "prompt too long; exceeded max context length by X tokens" + */ +const OVERFLOW_PATTERNS = [ + /prompt is too long/i, // Anthropic token overflow + /request_too_large/i, // Anthropic request byte-size overflow (HTTP 413) + /input is too long for requested model/i, // Amazon Bedrock + /exceeds the context window/i, // OpenAI (Completions & Responses API) + /exceeds (?:the )?(?:model'?s )?maximum context length of [\d,]+ tokens?/i, // OpenAI-compatible proxies (LiteLLM) + /input token count.*exceeds the maximum/i, // Google (Gemini) + /maximum prompt length is \d+/i, // xAI (Grok) + /reduce the length of the messages/i, // Groq + /maximum context length is \d+ tokens/i, // OpenRouter (all backends) + /input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i, // Together AI + /exceeds the limit of \d+/i, // GitHub Copilot + /exceeds the available context size/i, // llama.cpp server + /greater than the context length/i, // LM Studio + /context window exceeds limit/i, // MiniMax + /exceeded model token limit/i, // Kimi For Coding + /too large for model with \d+ maximum context length/i, // Mistral + /model_context_window_exceeded/i, // z.ai non-standard finish_reason surfaced as error text + /prompt too long; exceeded (?:max )?context length/i, // Ollama explicit overflow error + /context[_ ]length[_ ]exceeded/i, // Generic fallback + /too many tokens/i, // Generic fallback + /token limit exceeded/i, // Generic fallback + /^4(?:00|13)\s*(?:status code)?\s*\(no body\)/i, // Cerebras: 400/413 with no body +]; + +/** + * Patterns that indicate non-overflow errors (e.g. rate limiting, server errors). + * Error messages matching any of these are excluded from overflow detection + * even if they also match an OVERFLOW_PATTERN. + * + * Example: Bedrock formats throttling errors as "ThrottlingException: Too many tokens, + * please wait before trying again." which would match the /too many tokens/i overflow + * pattern without this exclusion. + */ +const NON_OVERFLOW_PATTERNS = [ + /^(Throttling error|Service unavailable):/i, // AWS Bedrock non-overflow errors (human-readable prefixes from formatBedrockError) + /rate limit/i, // Generic rate limiting + /too many requests/i, // Generic HTTP 429 style +]; + +/** + * Check if an assistant message represents a context overflow error. + * + * This handles two cases: + * 1. Error-based overflow: Most providers return stopReason "error" with a + * specific error message pattern. + * 2. Silent overflow: Some providers accept overflow requests and return + * successfully. For these, we check if usage.input exceeds the context window. + * + * ## Reliability by Provider + * + * **Reliable detection (returns error with detectable message):** + * - Anthropic: "prompt is too long: X tokens > Y maximum" or "request_too_large" + * - OpenAI (Completions & Responses): "exceeds the context window" or "exceeds the model's maximum context length of X tokens" + * - Google Gemini: "input token count exceeds the maximum" + * - xAI (Grok): "maximum prompt length is X but request contains Y" + * - Groq: "reduce the length of the messages" + * - Cerebras: 400/413 status code (no body) + * - Mistral: "Prompt contains X tokens ... too large for model with Y maximum context length" + * - OpenRouter (all backends): "maximum context length is X tokens" + * - Together AI: "The input (X tokens) is longer than the model's context length (Y tokens)." + * - llama.cpp: "exceeds the available context size" + * - LM Studio: "greater than the context length" + * - Kimi For Coding: "exceeded model token limit: X (requested: Y)" + * + * **Unreliable detection:** + * - z.ai: Sometimes accepts overflow silently (detectable via usage.input > contextWindow), + * sometimes returns rate limit errors. Pass contextWindow param to detect silent overflow. + * - Xiaomi MiMo: Truncates input to fit contextWindow then returns stopReason "length" with + * output=0. Pass contextWindow param to detect via the "filled context + zero output" signal. + * - Ollama: May truncate input silently for some setups, but may also return explicit + * overflow errors that match the patterns above. Silent truncation still cannot be + * detected here because we do not know the expected token count. + * + * ## Custom Providers + * + * If you've added custom models via settings.json, this function may not detect + * overflow errors from those providers. To add support: + * + * 1. Send a request that exceeds the model's context window + * 2. Check the errorMessage in the response + * 3. Create a regex pattern that matches the error + * 4. The pattern should be added to OVERFLOW_PATTERNS in this file, or + * check the errorMessage yourself before calling this function + * + * @param message - The assistant message to check + * @param contextWindow - Optional context window size for detecting silent overflow (z.ai) + * @returns true if the message indicates a context overflow + */ +export function isContextOverflow(message: AssistantMessage, contextWindow?: number): boolean { + // Case 1: Check error message patterns + if (message.stopReason === "error" && message.errorMessage) { + // Skip messages matching known non-overflow patterns (e.g. throttling / rate-limit) + const isNonOverflow = NON_OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!)); + if (!isNonOverflow && OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!))) { + return true; + } + } + + // Case 2: Silent overflow (z.ai style) - successful but usage exceeds context + if (contextWindow && message.stopReason === "stop") { + const inputTokens = message.usage.input + message.usage.cacheRead; + if (inputTokens > contextWindow) { + return true; + } + } + + // Case 3: Length-stop overflow (Xiaomi MiMo style) - server truncates oversized input + // to fit the context window, leaving no room for output. Returns stopReason "length" + // with output=0 and input+cacheRead filling the context window. + if (contextWindow && message.stopReason === "length" && message.usage.output === 0) { + const inputTokens = message.usage.input + message.usage.cacheRead; + if (inputTokens >= contextWindow * 0.99) { + return true; + } + } + + return false; +} + +/** + * Get the overflow patterns for testing purposes. + */ +export function getOverflowPatterns(): RegExp[] { + return [...OVERFLOW_PATTERNS]; +} diff --git a/emain/ai/utils/sanitize-unicode.ts b/emain/ai/utils/sanitize-unicode.ts new file mode 100644 index 000000000..d869ee9dc --- /dev/null +++ b/emain/ai/utils/sanitize-unicode.ts @@ -0,0 +1,25 @@ +/** + * Removes unpaired Unicode surrogate characters from a string. + * + * Unpaired surrogates (high surrogates 0xD800-0xDBFF without matching low surrogates 0xDC00-0xDFFF, + * or vice versa) cause JSON serialization errors in many API providers. + * + * Valid emoji and other characters outside the Basic Multilingual Plane use properly paired + * surrogates and will NOT be affected by this function. + * + * @param text - The text to sanitize + * @returns The sanitized text with unpaired surrogates removed + * + * @example + * // Valid emoji (properly paired surrogates) are preserved + * sanitizeSurrogates("Hello 🙈 World") // => "Hello 🙈 World" + * + * // Unpaired high surrogate is removed + * const unpaired = String.fromCharCode(0xD83D); // high surrogate without low + * sanitizeSurrogates(`Text ${unpaired} here`) // => "Text here" + */ +export function sanitizeSurrogates(text: string): string { + // Replace unpaired high surrogates (0xD800-0xDBFF not followed by low surrogate) + // Replace unpaired low surrogates (0xDC00-0xDFFF not preceded by high surrogate) + return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?; // "add" | "subtract" | "multiply" | "divide" + */ +export function StringEnum( + values: T, + options?: { description?: string; default?: T[number] }, +): TUnsafe { + return Type.Unsafe({ + type: "string", + enum: values as any, + ...(options?.description && { description: options.description }), + ...(options?.default && { default: options.default }), + }); +} diff --git a/emain/ai/utils/validation.ts b/emain/ai/utils/validation.ts new file mode 100644 index 000000000..252a1a046 --- /dev/null +++ b/emain/ai/utils/validation.ts @@ -0,0 +1,324 @@ +import { Compile } from "typebox/compile"; +import type { TLocalizedValidationError } from "typebox/error"; +import { Value } from "typebox/value"; +import type { Tool, ToolCall } from "../types"; + +const validatorCache = new WeakMap>(); +const TYPEBOX_KIND = Symbol.for("TypeBox.Kind"); + +interface JsonSchemaObject { + type?: string | string[]; + properties?: Record; + items?: JsonSchemaObject | JsonSchemaObject[]; + additionalProperties?: boolean | JsonSchemaObject; + allOf?: JsonSchemaObject[]; + anyOf?: JsonSchemaObject[]; + oneOf?: JsonSchemaObject[]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isJsonSchemaObject(value: unknown): value is JsonSchemaObject { + return isRecord(value); +} + +function hasTypeBoxMetadata(schema: unknown): boolean { + return isRecord(schema) && Object.getOwnPropertySymbols(schema).includes(TYPEBOX_KIND); +} + +function getSchemaTypes(schema: JsonSchemaObject): string[] { + if (typeof schema.type === "string") { + return [schema.type]; + } + if (Array.isArray(schema.type)) { + return schema.type.filter((type): type is string => typeof type === "string"); + } + return []; +} + +function matchesJsonType(value: unknown, type: string): boolean { + switch (type) { + case "number": + return typeof value === "number"; + case "integer": + return typeof value === "number" && Number.isInteger(value); + case "boolean": + return typeof value === "boolean"; + case "string": + return typeof value === "string"; + case "null": + return value === null; + case "array": + return Array.isArray(value); + case "object": + return isRecord(value) && !Array.isArray(value); + default: + return false; + } +} + +function isValidatorSchema(value: unknown): value is Tool["parameters"] { + return isRecord(value); +} + +function getSubSchemaValidator(schema: JsonSchemaObject): ReturnType | undefined { + if (!isValidatorSchema(schema)) { + return undefined; + } + try { + return getValidator(schema); + } catch { + return undefined; + } +} + +function coercePrimitiveByType(value: unknown, type: string): unknown { + switch (type) { + case "number": { + if (value === null) { + return 0; + } + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + if (typeof value === "boolean") { + return value ? 1 : 0; + } + return value; + } + case "integer": { + if (value === null) { + return 0; + } + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + if (Number.isInteger(parsed)) { + return parsed; + } + } + if (typeof value === "boolean") { + return value ? 1 : 0; + } + return value; + } + case "boolean": { + if (value === null) { + return false; + } + if (typeof value === "string") { + if (value === "true") { + return true; + } + if (value === "false") { + return false; + } + } + if (typeof value === "number") { + if (value === 1) { + return true; + } + if (value === 0) { + return false; + } + } + return value; + } + case "string": { + if (value === null) { + return ""; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return value; + } + case "null": { + if (value === "" || value === 0 || value === false) { + return null; + } + return value; + } + default: + return value; + } +} + +function applySchemaObjectCoercion(value: Record, schema: JsonSchemaObject): void { + const properties = schema.properties; + const definedKeys = new Set(properties ? Object.keys(properties) : []); + + if (properties) { + for (const [key, propertySchema] of Object.entries(properties)) { + if (!(key in value)) { + continue; + } + value[key] = coerceWithJsonSchema(value[key], propertySchema); + } + } + + if (schema.additionalProperties && isJsonSchemaObject(schema.additionalProperties)) { + for (const [key, propertyValue] of Object.entries(value)) { + if (definedKeys.has(key)) { + continue; + } + value[key] = coerceWithJsonSchema(propertyValue, schema.additionalProperties); + } + } +} + +function applySchemaArrayCoercion(value: unknown[], schema: JsonSchemaObject): void { + if (Array.isArray(schema.items)) { + for (let index = 0; index < value.length; index++) { + const itemSchema = schema.items[index]; + if (!itemSchema) { + continue; + } + value[index] = coerceWithJsonSchema(value[index], itemSchema); + } + return; + } + + if (isJsonSchemaObject(schema.items)) { + for (let index = 0; index < value.length; index++) { + value[index] = coerceWithJsonSchema(value[index], schema.items); + } + } +} + +function coerceWithUnionSchema(value: unknown, schemas: JsonSchemaObject[]): unknown { + for (const schema of schemas) { + const candidate = structuredClone(value); + const coerced = coerceWithJsonSchema(candidate, schema); + const validator = getSubSchemaValidator(schema); + if (validator?.Check(coerced)) { + return coerced; + } + } + return value; +} + +function coerceWithJsonSchema(value: unknown, schema: JsonSchemaObject): unknown { + let nextValue = value; + + if (Array.isArray(schema.allOf)) { + for (const nested of schema.allOf) { + nextValue = coerceWithJsonSchema(nextValue, nested); + } + } + + if (Array.isArray(schema.anyOf)) { + nextValue = coerceWithUnionSchema(nextValue, schema.anyOf); + } + + if (Array.isArray(schema.oneOf)) { + nextValue = coerceWithUnionSchema(nextValue, schema.oneOf); + } + + const schemaTypes = getSchemaTypes(schema); + const matchesUnionMember = + schemaTypes.length > 1 && schemaTypes.some((schemaType) => matchesJsonType(nextValue, schemaType)); + if (schemaTypes.length > 0 && !matchesUnionMember) { + for (const schemaType of schemaTypes) { + const candidate = coercePrimitiveByType(nextValue, schemaType); + if (candidate !== nextValue) { + nextValue = candidate; + break; + } + } + } + + if (schemaTypes.includes("object") && isRecord(nextValue) && !Array.isArray(nextValue)) { + applySchemaObjectCoercion(nextValue, schema); + } + + if (schemaTypes.includes("array") && Array.isArray(nextValue)) { + applySchemaArrayCoercion(nextValue, schema); + } + + return nextValue; +} + +function getValidator(schema: Tool["parameters"]): ReturnType { + const key = schema as object; + const cached = validatorCache.get(key); + if (cached) { + return cached; + } + const validator = Compile(schema); + validatorCache.set(key, validator); + return validator; +} + +function formatValidationPath(error: TLocalizedValidationError): string { + if (error.keyword === "required") { + const requiredProperties = (error.params as { requiredProperties?: string[] }).requiredProperties; + const requiredProperty = requiredProperties?.[0]; + if (requiredProperty) { + const basePath = error.instancePath.replace(/^\//, "").replace(/\//g, "."); + return basePath ? `${basePath}.${requiredProperty}` : requiredProperty; + } + } + const path = error.instancePath.replace(/^\//, "").replace(/\//g, "."); + return path || "root"; +} + +/** + * Finds a tool by name and validates the tool call arguments against its TypeBox schema + * @param tools Array of tool definitions + * @param toolCall The tool call from the LLM + * @returns The validated arguments + * @throws Error if tool is not found or validation fails + */ +export function validateToolCall(tools: Tool[], toolCall: ToolCall): any { + const tool = tools.find((t) => t.name === toolCall.name); + if (!tool) { + throw new Error(`Tool "${toolCall.name}" not found`); + } + return validateToolArguments(tool, toolCall); +} + +/** + * Validates tool call arguments against the tool's TypeBox schema + * @param tool The tool definition with TypeBox schema + * @param toolCall The tool call from the LLM + * @returns The validated (and potentially coerced) arguments + * @throws Error with formatted message if validation fails + */ +export function validateToolArguments(tool: Tool, toolCall: ToolCall): any { + const args = structuredClone(toolCall.arguments); + Value.Convert(tool.parameters, args); + + const validator = getValidator(tool.parameters); + if (!hasTypeBoxMetadata(tool.parameters) && isJsonSchemaObject(tool.parameters)) { + const coerced = coerceWithJsonSchema(args, tool.parameters); + if (coerced !== args) { + if (isRecord(args) && isRecord(coerced)) { + for (const key of Object.keys(args)) { + delete args[key]; + } + Object.assign(args, coerced); + } else { + return validator.Check(coerced) ? coerced : args; + } + } + } + + if (validator.Check(args)) { + return args; + } + + const errors = + validator + .Errors(args) + .map((error) => ` - ${formatValidationPath(error)}: ${error.message}`) + .join("\n") || "Unknown validation error"; + + const errorMessage = `Validation failed for tool "${toolCall.name}":\n${errors}\n\nReceived arguments:\n${JSON.stringify(toolCall.arguments, null, 2)}`; + + throw new Error(errorMessage); +} diff --git a/emain/aiconfig-ipc.ts b/emain/aiconfig-ipc.ts new file mode 100644 index 000000000..a7d11339f --- /dev/null +++ b/emain/aiconfig-ipc.ts @@ -0,0 +1,78 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +// +// aiconfig-ipc.ts — IPC handlers for the AI config + provider listing +// surface that used to live in Go (pkg/aiusechat/listmodels.go and the +// ListProviderModelsCommand wshrpc). The renderer's model picker now +// invokes these directly via `window.api.ai.*`. +// +// IPC contract (mirrored in preload.ts + ElectronApi.ai typings): +// +// handle "ai:list-provider-models" (input) +// input = { apitype, baseurl?, apitoken?, tokensecretname? } +// - if apitoken is empty and tokensecretname is set, the handler +// resolves it via the safeStorage-backed secrets file. +// - returns ProviderModelInfo[] +// +// The aiconfig dir is separate from emain/ai/ because emain/ai/ holds +// the pi-ai library source we vendored — keeping crest-specific config +// glue in its own dir avoids confusion with the library. + +import * as electron from "electron"; + +import { + listProviderModels, + type ListProviderModelsInput, + type ProviderModelInfo, +} from "./aiconfig/list-provider-models"; +import { getSecret } from "./aiconfig/secrets"; +import { + readAIUserConfig, + writeAIUserConfig, + type AIUserConfigReadResult, +} from "./aiconfig/user-config"; + +interface ListProviderModelsIpcInput extends ListProviderModelsInput { + tokensecretname?: string; +} + +/** + * Wire the AI config / listing IPC handlers. Call once at app startup + * from emain-ipc.ts initIpcHandlers(). + */ +export function registerAiConfigIpcHandlers(): void { + electron.ipcMain.handle( + "ai:list-provider-models", + async (_event, input: ListProviderModelsIpcInput): Promise => { + let token = (input.apitoken ?? "").trim(); + if (!token && input.tokensecretname) { + const value = await getSecret(input.tokensecretname); + if (value == null) { + throw new Error( + `secret "${input.tokensecretname}" not found — open Settings → AI Provider to set the key`, + ); + } + token = value; + } + return listProviderModels({ + apitype: input.apitype, + baseurl: input.baseurl, + apitoken: token, + }); + }, + ); + + electron.ipcMain.handle( + "ai:get-user-config", + async (): Promise => { + return readAIUserConfig(); + }, + ); + + electron.ipcMain.handle( + "ai:write-user-config", + async (_event, cfg: Parameters[0]): Promise => { + await writeAIUserConfig(cfg); + }, + ); +} diff --git a/emain/aiconfig/list-provider-models.ts b/emain/aiconfig/list-provider-models.ts new file mode 100644 index 000000000..d678dea86 --- /dev/null +++ b/emain/aiconfig/list-provider-models.ts @@ -0,0 +1,246 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +// +// TS port of the old pkg/aiusechat/listmodels.go. Hits a provider's +// /v1/models (or equivalent) endpoint and normalizes the response into +// the ProviderModelInfo shape the renderer's picker consumes. +// +// Three provider variants: +// - openai-compat (OpenAI, OpenRouter, Together, Mistral, Groq, DeepSeek) +// - anthropic-messages (Anthropic, header + x-api-key) +// - google-gemini (?key=... query string, displayName/inputTokenLimit shape) + +const REQUEST_TIMEOUT_MS = 15_000; + +// Mirror of the old wshrpc.ProviderModelInfo Go struct. Lowercase JSON +// keys preserved so the renderer doesn't need a translation layer. +export interface ProviderModelInfo { + id: string; + name?: string; + description?: string; + context?: number; + maxoutputtokens?: number; + promptcost?: number; + completioncost?: number; + imagecost?: number; + requestcost?: number; + inputmodalities?: string[]; + tokenizer?: string; + ismoderated?: boolean; +} + +export interface ListProviderModelsInput { + apitype: string; + baseurl?: string; + apitoken?: string; +} + +// Match the API-type string constants from pkg/aiusechat/uctypes/userconfig.go. +export const APIType_AnthropicMessages = "anthropic-messages"; +export const APIType_GoogleGemini = "google-gemini"; +export const APIType_OpenAIChat = "openai-chat"; +export const APIType_OpenAIResponses = "openai-responses"; + +export async function listProviderModels( + input: ListProviderModelsInput, +): Promise { + const apiType = (input.apitype ?? "").trim(); + const baseUrl = (input.baseurl ?? "").trim(); + const apiToken = (input.apitoken ?? "").trim(); + if (!apiType) throw new Error("apitype is required"); + validateBaseUrl(baseUrl); + switch (apiType) { + case APIType_AnthropicMessages: + return listAnthropicModels(baseUrl, apiToken); + case APIType_GoogleGemini: + return listGeminiModels(baseUrl, apiToken); + case APIType_OpenAIChat: + case APIType_OpenAIResponses: + return listOpenAiCompatibleModels(baseUrl, apiToken); + default: + // Unknown apiType — best-effort try the OpenAI-compatible path. + return listOpenAiCompatibleModels(baseUrl, apiToken); + } +} + +// Guards against settings that would smuggle the user's API key to a +// non-http scheme (file://, gopher://) or to a host-less URL. +function validateBaseUrl(baseUrl: string): void { + if (!baseUrl) return; + let u: URL; + try { + u = new URL(baseUrl); + } catch (err) { + throw new Error(`invalid baseurl: ${(err as Error).message}`); + } + if (u.protocol !== "http:" && u.protocol !== "https:") { + throw new Error(`baseurl must use http or https scheme, got "${u.protocol}"`); + } + if (!u.host) { + throw new Error("baseurl must include a host"); + } +} + +// modelsUrlFromChatUrl — derive the /models endpoint from a provider's +// configured *chat* URL. Works for OpenAI / OpenRouter / Together / +// Mistral / Groq / DeepSeek / Anthropic. Identity for already-correct URLs. +function modelsUrlFromChatUrl(chatUrl: string): string { + if (!chatUrl) return ""; + const s = chatUrl.replace(/\/+$/, ""); + for (const suffix of ["/chat/completions", "/responses", "/messages", "/completions"]) { + if (s.endsWith(suffix)) return s.slice(0, -suffix.length) + "/models"; + } + if (s.endsWith("/models")) return s; + return s + "/models"; +} + +async function listOpenAiCompatibleModels( + baseUrl: string, + apiToken: string, +): Promise { + const endpoint = modelsUrlFromChatUrl(baseUrl) || "https://api.openai.com/v1/models"; + const headers: Record = {}; + if (apiToken) headers.Authorization = `Bearer ${apiToken}`; + const body = await doRequest(endpoint, { headers }); + // OpenAI returns {data: [{id, ...}]}. OpenRouter returns the same + // envelope with name/description/context_length plus nested pricing + // and architecture / top_provider blocks. One decode absorbs both. + interface OaiDataItem { + id?: string; + name?: string; + description?: string; + context_length?: number; + pricing?: { prompt?: string; completion?: string; image?: string; request?: string }; + top_provider?: { max_completion_tokens?: number; is_moderated?: boolean }; + architecture?: { input_modalities?: string[]; tokenizer?: string }; + } + const resp = JSON.parse(body) as { data?: OaiDataItem[] }; + const out: ProviderModelInfo[] = []; + for (const m of resp.data ?? []) { + if (!m.id) continue; + out.push({ + id: m.id, + name: m.name, + description: m.description, + context: m.context_length, + maxoutputtokens: m.top_provider?.max_completion_tokens, + promptcost: parseUsdRate(m.pricing?.prompt), + completioncost: parseUsdRate(m.pricing?.completion), + imagecost: parseUsdRate(m.pricing?.image), + requestcost: parseUsdRate(m.pricing?.request), + inputmodalities: m.architecture?.input_modalities, + tokenizer: m.architecture?.tokenizer, + ismoderated: m.top_provider?.is_moderated, + }); + } + return sortModels(out); +} + +async function listAnthropicModels( + baseUrl: string, + apiToken: string, +): Promise { + if (!apiToken) throw new Error("Anthropic /models requires an API key"); + const endpoint = modelsUrlFromChatUrl(baseUrl) || "https://api.anthropic.com/v1/models"; + const body = await doRequest(endpoint, { + headers: { + "x-api-key": apiToken, + "anthropic-version": "2023-06-01", + }, + }); + interface AnthropicItem { + id?: string; + display_name?: string; + } + const resp = JSON.parse(body) as { data?: AnthropicItem[] }; + const out: ProviderModelInfo[] = []; + for (const m of resp.data ?? []) { + if (!m.id) continue; + out.push({ id: m.id, name: m.display_name }); + } + return sortModels(out); +} + +async function listGeminiModels( + baseUrl: string, + apiToken: string, +): Promise { + if (!apiToken) throw new Error("Gemini /models requires an API key"); + // Gemini puts the key in the query string. The chat URL is per-model + // (.../models/:streamGenerateContent), so derive the v1beta root + // by snipping at /models, then list at /models with ?key=... + const endpoint = geminiModelsUrl(baseUrl); + const parsed = new URL(endpoint); + parsed.searchParams.set("key", apiToken); + const body = await doRequest(parsed.toString(), { headers: {} }); + interface GeminiItem { + name?: string; + displayName?: string; + description?: string; + inputTokenLimit?: number; + supportedGenerationMethods?: string[]; + } + const resp = JSON.parse(body) as { models?: GeminiItem[] }; + const out: ProviderModelInfo[] = []; + for (const m of resp.models ?? []) { + if (!supportsGeneration(m.supportedGenerationMethods)) continue; + const id = (m.name ?? "").replace(/^models\//, ""); + if (!id) continue; + out.push({ + id, + name: m.displayName, + description: m.description, + context: m.inputTokenLimit, + }); + } + return sortModels(out); +} + +function geminiModelsUrl(chatUrl: string): string { + if (!chatUrl) return "https://generativelanguage.googleapis.com/v1beta/models"; + const s = chatUrl.replace(/\/+$/, ""); + const idx = s.indexOf("/models"); + if (idx >= 0) return s.slice(0, idx) + "/models"; + return s + "/models"; +} + +function supportsGeneration(methods?: string[]): boolean { + if (!methods) return false; + return methods.includes("generateContent") || methods.includes("streamGenerateContent"); +} + +function parseUsdRate(s?: string): number { + if (!s) return 0; + const v = Number.parseFloat(s.trim()); + return Number.isFinite(v) ? v : 0; +} + +function sortModels(models: ProviderModelInfo[]): ProviderModelInfo[] { + return models.sort((a, b) => a.id.toLowerCase().localeCompare(b.id.toLowerCase())); +} + +async function doRequest( + url: string, + init: { headers: Record }, +): Promise { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS); + try { + const res = await fetch(url, { method: "GET", headers: init.headers, signal: ctrl.signal }); + const text = await res.text(); + if (res.status < 200 || res.status >= 300) { + // Surface the upstream body verbatim (truncated) so the user can + // see "invalid api key" / etc. directly in the picker. + const snippet = text.length > 500 ? text.slice(0, 500) + "..." : text; + throw new Error(`provider returned ${res.status}: ${snippet}`); + } + return text; + } catch (err: unknown) { + if ((err as Error).name === "AbortError") { + throw new Error(`request timed out after ${REQUEST_TIMEOUT_MS}ms`); + } + throw err; + } finally { + clearTimeout(timer); + } +} diff --git a/emain/aiconfig/secrets.ts b/emain/aiconfig/secrets.ts new file mode 100644 index 000000000..15a3e0557 --- /dev/null +++ b/emain/aiconfig/secrets.ts @@ -0,0 +1,74 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +// +// Electron-main reader for the safeStorage-encrypted secrets file at +// `/secrets.enc`. Previously the Go wavesrv side wrapped this +// (pkg/secretstore/secretstore.go) and reached into Electron via the +// ElectronEncryptCommand wshrpc to do the actual encrypt/decrypt. Now +// that the AI runtime lives in main, we read the file directly here — +// safeStorage is a main-process API, no IPC roundtrip required. +// +// Write path stays in Go for now (the secrets-management UI in +// frontend/app/modals/ai-setup-wizard.tsx submits writes via wshrpc, +// which goes to Go → ElectronEncryptCommand → safeStorage). We only +// need read access here for resolving `tokensecretname` references +// when listing provider /models. + +import { safeStorage } from "electron"; +import { promises as fs } from "node:fs"; +import * as path from "node:path"; + +import { getWaveConfigDir } from "../emain-platform"; + +const SECRETS_FILE_NAME = "secrets.enc"; + +// Match pkg/secretstore — the file contains JSON {name: value, ...} +// plus a "wave:writets" key we filter out. +const WRITE_TS_KEY = "wave:writets"; + +interface CacheEntry { + secrets: Record; + mtimeMs: number; +} + +let cache: CacheEntry | null = null; + +async function loadSecrets(): Promise> { + const filePath = path.join(getWaveConfigDir(), SECRETS_FILE_NAME); + let stat; + try { + stat = await fs.stat(filePath); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return {}; + throw err; + } + if (cache && cache.mtimeMs === stat.mtimeMs) { + return cache.secrets; + } + if (!safeStorage.isEncryptionAvailable()) { + throw new Error("safeStorage encryption is not available"); + } + const raw = await fs.readFile(filePath, "utf8"); + const buf = Buffer.from(raw, "base64"); + const plaintext = safeStorage.decryptString(buf); + const parsed = JSON.parse(plaintext) as Record; + cache = { secrets: parsed, mtimeMs: stat.mtimeMs }; + return parsed; +} + +/** + * Resolve a secret by name. Returns null when the file doesn't exist, + * the name isn't present, or the name matches the reserved metadata + * key. Throws on decrypt / parse errors so the caller surfaces them. + */ +export async function getSecret(name: string): Promise { + if (!name || name === WRITE_TS_KEY) return null; + const secrets = await loadSecrets(); + const value = secrets[name]; + return value ?? null; +} + +/** Test-only: clear the in-memory cache so the next call re-reads the file. */ +export function _resetSecretsCacheForTests(): void { + cache = null; +} diff --git a/emain/aiconfig/user-config.ts b/emain/aiconfig/user-config.ts new file mode 100644 index 000000000..c2980c446 --- /dev/null +++ b/emain/aiconfig/user-config.ts @@ -0,0 +1,113 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +// +// TS port of pkg/aiusechat/aiconfig.go — atomic read / write of +// `~/.config/crest/ai.json`. Read returns a status-tagged response so +// the renderer can distinguish "fresh install" from "file is broken" +// without resorting to error-string sniffing. +// +// Validation is shape-level only (presence of required fields). The +// frontend resolver still does cross-reference validation (does +// default.provider exist in providers?) because catalog data lives +// there. +// +// Shape mirrors frontend/app/store/ai-types.ts UserConfig — JSON keys +// must stay in sync across the boundary. + +import { promises as fs } from "node:fs"; +import * as path from "node:path"; + +import { getWaveConfigDir } from "../emain-platform"; + +const AI_USER_CONFIG_FILE = "ai.json"; + +// Lock so concurrent writes (e.g. picker + wizard at the same time) +// don't tear. Single in-flight writer at a time. +let writeChain: Promise = Promise.resolve(); + +interface ProviderCredentials { + tokensecretname?: string; + token?: string; +} + +interface AISelectionConfig { + provider: string; + model: string; + reasoning?: string; +} + +// Loose typing — we revalidate shape on write; the renderer's UserConfig +// is the authoritative TS shape. This stays permissive so renderer-only +// fields don't trigger spurious rejections here. +type AIUserConfig = { + providers: Record; + default: AISelectionConfig; + [k: string]: unknown; +}; + +export type AIUserConfigReadStatus = "ok" | "missing" | "malformed"; + +export interface AIUserConfigReadResult { + status: AIUserConfigReadStatus; + config?: AIUserConfig; + error?: string; +} + +export async function readAIUserConfig(): Promise { + const filePath = aiUserConfigPath(); + let data: string; + try { + data = await fs.readFile(filePath, "utf8"); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return { status: "missing" }; + return { status: "malformed", error: `read ${filePath}: ${(err as Error).message}` }; + } + if (!data) return { status: "malformed", error: "file is empty" }; + let cfg: AIUserConfig; + try { + cfg = JSON.parse(data) as AIUserConfig; + } catch (err: unknown) { + return { status: "malformed", error: (err as Error).message }; + } + const validationErr = validateAIUserConfig(cfg); + if (validationErr) return { status: "malformed", error: validationErr }; + return { status: "ok", config: cfg }; +} + +export async function writeAIUserConfig(cfg: AIUserConfig): Promise { + if (cfg == null) throw new Error("write ai user config: cfg is nil"); + const validationErr = validateAIUserConfig(cfg); + if (validationErr) throw new Error(`malformed ai user config: ${validationErr}`); + const next = writeChain.then(() => doWrite(cfg)).catch(() => doWrite(cfg)); + writeChain = next.catch(() => undefined); + return next; +} + +async function doWrite(cfg: AIUserConfig): Promise { + const filePath = aiUserConfigPath(); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + const payload = JSON.stringify(cfg, null, 4); + // Write to a sibling tmp file then atomic rename. Avoids leaving a + // half-written ai.json if the process dies mid-write. + const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`; + await fs.writeFile(tmp, payload, { encoding: "utf8", mode: 0o644 }); + await fs.rename(tmp, filePath); +} + +function aiUserConfigPath(): string { + return path.join(getWaveConfigDir(), AI_USER_CONFIG_FILE); +} + +function validateAIUserConfig(cfg: AIUserConfig): string | null { + if (!cfg || typeof cfg !== "object") return "missing required field: providers"; + if (!cfg.providers || typeof cfg.providers !== "object") { + return "missing required field: providers"; + } + if (Object.keys(cfg.providers).length === 0) { + return "providers map is empty — add at least one provider entry"; + } + if (!cfg.default) return "missing required field: default"; + if (!cfg.default.provider) return "missing required field: default.provider"; + if (!cfg.default.model) return "missing required field: default.model"; + return null; +} diff --git a/emain/authkey.ts b/emain/authkey.ts new file mode 100644 index 000000000..e481c7ca5 --- /dev/null +++ b/emain/authkey.ts @@ -0,0 +1,23 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { ipcMain } from "electron"; +import { getWebServerEndpoint, getWSServerEndpoint } from "../frontend/util/endpoints"; + +const AuthKeyHeader = "X-AuthKey"; +export const WaveAuthKeyEnv = "WAVETERM_AUTH_KEY"; +export const AuthKey = crypto.randomUUID(); + +ipcMain.on("get-auth-key", (event) => { + event.returnValue = AuthKey; +}); + +export function configureAuthKeyRequestInjection(session: Electron.Session) { + const filter: Electron.WebRequestFilter = { + urls: [`${getWebServerEndpoint()}/*`, `${getWSServerEndpoint()}/*`], + }; + session.webRequest.onBeforeSendHeaders(filter, (details, callback) => { + details.requestHeaders[AuthKeyHeader] = AuthKey; + callback({ requestHeaders: details.requestHeaders }); + }); +} diff --git a/emain/emain-activity.ts b/emain/emain-activity.ts new file mode 100644 index 000000000..17dde466a --- /dev/null +++ b/emain/emain-activity.ts @@ -0,0 +1,107 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// for activity updates +let wasActive = true; +let wasInFg = true; +let globalIsQuitting = false; +let globalIsStarting = true; +let globalIsRelaunching = false; +let forceQuit = false; +let userConfirmedQuit = false; +let termCommandsRun = 0; +let termCommandsRemote = 0; +let termCommandsWsl = 0; +let termCommandsDurable = 0; + +export function setWasActive(val: boolean) { + wasActive = val; +} + +export function setWasInFg(val: boolean) { + wasInFg = val; +} + +export function getActivityState(): { wasActive: boolean; wasInFg: boolean } { + return { wasActive, wasInFg }; +} + +export function setGlobalIsQuitting(val: boolean) { + globalIsQuitting = val; +} + +export function getGlobalIsQuitting(): boolean { + return globalIsQuitting; +} + +export function setGlobalIsStarting(val: boolean) { + globalIsStarting = val; +} + +export function getGlobalIsStarting(): boolean { + return globalIsStarting; +} + +export function setGlobalIsRelaunching(val: boolean) { + globalIsRelaunching = val; +} + +export function getGlobalIsRelaunching(): boolean { + return globalIsRelaunching; +} + +export function setForceQuit(val: boolean) { + forceQuit = val; +} + +export function getForceQuit(): boolean { + return forceQuit; +} + +export function setUserConfirmedQuit(val: boolean) { + userConfirmedQuit = val; +} + +export function getUserConfirmedQuit(): boolean { + return userConfirmedQuit; +} + +export function incrementTermCommandsRun() { + termCommandsRun++; +} + +export function getAndClearTermCommandsRun(): number { + const count = termCommandsRun; + termCommandsRun = 0; + return count; +} + +export function incrementTermCommandsRemote() { + termCommandsRemote++; +} + +export function getAndClearTermCommandsRemote(): number { + const count = termCommandsRemote; + termCommandsRemote = 0; + return count; +} + +export function incrementTermCommandsWsl() { + termCommandsWsl++; +} + +export function getAndClearTermCommandsWsl(): number { + const count = termCommandsWsl; + termCommandsWsl = 0; + return count; +} + +export function incrementTermCommandsDurable() { + termCommandsDurable++; +} + +export function getAndClearTermCommandsDurable(): number { + const count = termCommandsDurable; + termCommandsDurable = 0; + return count; +} diff --git a/emain/emain-builder.ts b/emain/emain-builder.ts new file mode 100644 index 000000000..8b223c0f9 --- /dev/null +++ b/emain/emain-builder.ts @@ -0,0 +1,135 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { ClientService } from "@/app/store/services"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { randomUUID } from "crypto"; +import { BrowserWindow, webContents } from "electron"; +import { globalEvents } from "emain/emain-events"; +import path from "path"; +import { getElectronAppBasePath, isDevVite, unamePlatform } from "./emain-platform"; +import { calculateWindowBounds, MinWindowHeight, MinWindowWidth } from "./emain-window"; +import { ElectronWshClient } from "./emain-wsh"; + +export type BuilderWindowType = BrowserWindow & { + builderId: string; + builderAppId?: string; + savedInitOpts: BuilderInitOpts; +}; + +const builderWindows: BuilderWindowType[] = []; +export let focusedBuilderWindow: BuilderWindowType = null; + +export function getBuilderWindowById(builderId: string): BuilderWindowType { + return builderWindows.find((win) => win.builderId === builderId); +} + +export function getBuilderWindowByWebContentsId(webContentsId: number): BuilderWindowType { + return builderWindows.find((win) => win.webContents.id === webContentsId); +} + +export function getAllBuilderWindows(): BuilderWindowType[] { + return builderWindows; +} + +export async function createBuilderWindow(appId: string): Promise { + const builderId = randomUUID(); + + const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); + const clientData = await ClientService.GetClientData(); + const clientId = clientData?.oid; + const windowId = randomUUID(); + + if (appId) { + const oref = `builder:${builderId}`; + await RpcApi.SetRTInfoCommand(ElectronWshClient, { + oref, + data: { "builder:appid": appId }, + }); + } + + const winBounds = calculateWindowBounds(undefined, undefined, fullConfig.settings); + + const builderWindow = new BrowserWindow({ + x: winBounds.x, + y: winBounds.y, + width: winBounds.width, + height: winBounds.height, + minWidth: MinWindowWidth, + minHeight: MinWindowHeight, + titleBarStyle: unamePlatform === "darwin" ? "hiddenInset" : "default", + icon: + unamePlatform === "linux" + ? path.join(getElectronAppBasePath(), "public/logos/wave-logo-dark.png") + : undefined, + show: false, + backgroundColor: "#222222", + webPreferences: { + preload: path.join(getElectronAppBasePath(), "preload", "index.cjs"), + webviewTag: true, + }, + }); + + if (isDevVite) { + await builderWindow.loadURL(`${process.env.ELECTRON_RENDERER_URL}/index.html`); + } else { + await builderWindow.loadFile(path.join(getElectronAppBasePath(), "frontend", "index.html")); + } + + const initOpts: BuilderInitOpts = { + builderId, + clientId, + windowId, + }; + + const typedBuilderWindow = builderWindow as BuilderWindowType; + typedBuilderWindow.builderId = builderId; + typedBuilderWindow.builderAppId = appId; + typedBuilderWindow.savedInitOpts = initOpts; + + typedBuilderWindow.on("close", () => { + const wc = typedBuilderWindow.webContents; + if (wc.isDevToolsOpened()) { + wc.closeDevTools(); + } + for (const guest of webContents.getAllWebContents()) { + if (guest.getType() === "webview" && guest.hostWebContents?.id === wc.id) { + if (guest.isDevToolsOpened()) { + guest.closeDevTools(); + } + } + } + }); + + typedBuilderWindow.on("focus", () => { + focusedBuilderWindow = typedBuilderWindow; + console.log("builder window focused", builderId); + setTimeout(() => globalEvents.emit("windows-updated"), 50); + }); + + typedBuilderWindow.on("blur", () => { + if (focusedBuilderWindow === typedBuilderWindow) { + focusedBuilderWindow = null; + } + setTimeout(() => globalEvents.emit("windows-updated"), 50); + }); + + typedBuilderWindow.on("closed", () => { + console.log("builder window closed", builderId); + const index = builderWindows.indexOf(typedBuilderWindow); + if (index !== -1) { + builderWindows.splice(index, 1); + } + if (focusedBuilderWindow === typedBuilderWindow) { + focusedBuilderWindow = null; + } + RpcApi.DeleteBuilderCommand(ElectronWshClient, builderId, { noresponse: true }); + setTimeout(() => globalEvents.emit("windows-updated"), 50); + }); + + builderWindows.push(typedBuilderWindow); + typedBuilderWindow.show(); + + console.log("created builder window", builderId, appId); + return typedBuilderWindow; +} diff --git a/emain/emain-events.ts b/emain/emain-events.ts new file mode 100644 index 000000000..08d13cd4f --- /dev/null +++ b/emain/emain-events.ts @@ -0,0 +1,30 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { EventEmitter } from "events"; + +interface GlobalEvents { + "windows-updated": () => void; // emitted whenever a window is opened/closed +} + +class GlobalEventEmitter extends EventEmitter { + emit(event: K, ...args: Parameters): boolean { + return super.emit(event, ...args); + } + + on(event: K, listener: GlobalEvents[K]): this { + return super.on(event, listener); + } + + once(event: K, listener: GlobalEvents[K]): this { + return super.once(event, listener); + } + + off(event: K, listener: GlobalEvents[K]): this { + return super.off(event, listener); + } +} + +const globalEvents = new GlobalEventEmitter(); + +export { globalEvents }; diff --git a/emain/emain-ipc.ts b/emain/emain-ipc.ts new file mode 100644 index 000000000..4ac3e4a8b --- /dev/null +++ b/emain/emain-ipc.ts @@ -0,0 +1,608 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as electron from "electron"; +import { FastAverageColor } from "fast-average-color"; +import fs from "fs"; +import * as child_process from "node:child_process"; +import * as path from "path"; +import { PNG } from "pngjs"; +import { Readable } from "stream"; +import { RpcApi } from "../frontend/app/store/wshclientapi"; +import { getWebServerEndpoint } from "../frontend/util/endpoints"; +import * as keyutil from "../frontend/util/keyutil"; +import { fireAndForget, parseDataUrl } from "../frontend/util/util"; +import { + incrementTermCommandsDurable, + incrementTermCommandsRemote, + incrementTermCommandsRun, + incrementTermCommandsWsl, + setWasActive, +} from "./emain-activity"; +import { createBuilderWindow, getAllBuilderWindows, getBuilderWindowByWebContentsId } from "./emain-builder"; +import { callWithOriginalXdgCurrentDesktopAsync, unamePlatform } from "./emain-platform"; +import { getWaveTabViewByWebContentsId } from "./emain-tabview"; +import { handleCtrlShiftState } from "./emain-util"; +import { getWaveVersion } from "./emain-wavesrv"; +import { createNewWaveWindow, getWaveWindowByWebContentsId } from "./emain-window"; +import { ElectronWshClient } from "./emain-wsh"; +import { registerAgentIpcHandlers } from "./agent-ipc"; +import { registerAiConfigIpcHandlers } from "./aiconfig-ipc"; + +const electronApp = electron.app; + +let webviewFocusId: number = null; +let webviewKeys: string[] = []; + +export function openBuilderWindow(appId?: string) { + const normalizedAppId = appId || ""; + const existingBuilderWindows = getAllBuilderWindows(); + const existingWindow = existingBuilderWindows.find((win) => win.builderAppId === normalizedAppId); + if (existingWindow) { + existingWindow.focus(); + return; + } + fireAndForget(() => createBuilderWindow(normalizedAppId)); +} + +type UrlInSessionResult = { + stream: Readable; + mimeType: string; + fileName: string; +}; + +function getSingleHeaderVal(headers: Record, key: string): string { + const val = headers[key]; + if (val == null) { + return null; + } + if (Array.isArray(val)) { + return val[0]; + } + return val; +} + +function cleanMimeType(mimeType: string): string { + if (mimeType == null) { + return null; + } + const parts = mimeType.split(";"); + return parts[0].trim(); +} + +function getFileNameFromUrl(url: string): string { + try { + const pathname = new URL(url).pathname; + const filename = pathname.substring(pathname.lastIndexOf("/") + 1); + return filename; + } catch (e) { + return null; + } +} + +function getUrlInSession(session: Electron.Session, url: string): Promise { + return new Promise((resolve, reject) => { + if (url.startsWith("data:")) { + try { + const parsed = parseDataUrl(url); + const buffer = Buffer.from(parsed.buffer); + const readable = Readable.from(buffer); + resolve({ stream: readable, mimeType: parsed.mimeType, fileName: "image" }); + } catch (err) { + return reject(err); + } + return; + } + const request = electron.net.request({ + url, + method: "GET", + session, + }); + const readable = new Readable({ + read() {}, + }); + request.on("response", (response) => { + const statusCode = response.statusCode; + if (statusCode < 200 || statusCode >= 300) { + readable.destroy(); + request.abort(); + reject(new Error(`HTTP request failed with status ${statusCode}: ${response.statusMessage || ""}`)); + return; + } + + const mimeType = cleanMimeType(getSingleHeaderVal(response.headers, "content-type")); + const fileName = getFileNameFromUrl(url) || "image"; + response.on("data", (chunk) => { + readable.push(chunk); + }); + response.on("end", () => { + readable.push(null); + resolve({ stream: readable, mimeType, fileName }); + }); + response.on("error", (err) => { + readable.destroy(err); + reject(err); + }); + }); + request.on("error", (err) => { + readable.destroy(err); + reject(err); + }); + request.end(); + }); +} + +function saveImageFileWithNativeDialog( + sender: electron.WebContents, + defaultFileName: string, + mimeType: string, + readStream: Readable +) { + if (defaultFileName == null || defaultFileName == "") { + defaultFileName = "image"; + } + const ww = electron.BrowserWindow.fromWebContents(sender); + if (ww == null) { + readStream.destroy(); + return; + } + const mimeToExtension: { [key: string]: string } = { + "image/png": "png", + "image/jpeg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + "image/bmp": "bmp", + "image/tiff": "tiff", + "image/heic": "heic", + "image/svg+xml": "svg", + }; + function addExtensionIfNeeded(fileName: string, mimeType: string): string { + const extension = mimeToExtension[mimeType]; + if (!path.extname(fileName) && extension) { + return `${fileName}.${extension}`; + } + return fileName; + } + defaultFileName = addExtensionIfNeeded(defaultFileName, mimeType); + electron.dialog + .showSaveDialog(ww, { + title: "Save Image", + defaultPath: defaultFileName, + filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", "heic"] }], + }) + .then((file) => { + if (file.canceled) { + readStream.destroy(); + return; + } + const writeStream = fs.createWriteStream(file.filePath); + readStream.pipe(writeStream); + writeStream.on("finish", () => { + console.log("saved file", file.filePath); + }); + writeStream.on("error", (err) => { + console.log("error saving file (writeStream)", err); + readStream.destroy(); + }); + readStream.on("error", (err) => { + console.error("error saving file (readStream)", err); + writeStream.destroy(); + }); + }) + .catch((err) => { + console.log("error trying to save file", err); + }); +} + +export function initIpcHandlers() { + // Agent runtime IPC (renderer ↔ Electron-main agent loop). + // See emain/agent-ipc.ts + docs/agent-runtime-architecture.md. + registerAgentIpcHandlers(); + + // AI config / provider /models listing IPC (replaces the Go-side + // ListProviderModelsCommand wshrpc). + registerAiConfigIpcHandlers(); + + electron.ipcMain.on("open-external", (event, url) => { + if (url && typeof url === "string") { + fireAndForget(() => + callWithOriginalXdgCurrentDesktopAsync(() => + electron.shell.openExternal(url).catch((err) => { + console.error(`Failed to open URL ${url}:`, err); + }) + ) + ); + } else { + console.error("Invalid URL received in open-external event:", url); + } + }); + + electron.ipcMain.on("webview-image-contextmenu", (event: electron.IpcMainEvent, payload: { src: string }) => { + const menu = new electron.Menu(); + const win = getWaveWindowByWebContentsId(event.sender.hostWebContents?.id); + if (win == null) { + return; + } + menu.append( + new electron.MenuItem({ + label: "Save Image", + click: () => { + const resultP = getUrlInSession(event.sender.session, payload.src); + resultP + .then((result) => { + saveImageFileWithNativeDialog( + event.sender.hostWebContents, + result.fileName, + result.mimeType, + result.stream + ); + }) + .catch((e) => { + console.log("error getting image", e); + }); + }, + }) + ); + menu.popup(); + }); + + electron.ipcMain.on("webview-mouse-navigate", (event: electron.IpcMainEvent, direction: string) => { + if (direction === "back") { + event.sender.navigationHistory.goBack(); + } else if (direction === "forward") { + event.sender.navigationHistory.goForward(); + } + }); + + electron.ipcMain.on("download", (event, payload) => { + const baseName = encodeURIComponent(path.basename(payload.filePath)); + const streamingUrl = + getWebServerEndpoint() + "/wave/stream-file/" + baseName + "?path=" + encodeURIComponent(payload.filePath); + event.sender.downloadURL(streamingUrl); + }); + + electron.ipcMain.on("get-cursor-point", (event) => { + const tabView = getWaveTabViewByWebContentsId(event.sender.id); + if (tabView == null) { + event.returnValue = null; + return; + } + const screenPoint = electron.screen.getCursorScreenPoint(); + const windowRect = tabView.getBounds(); + const retVal: Electron.Point = { + x: screenPoint.x - windowRect.x, + y: screenPoint.y - windowRect.y, + }; + event.returnValue = retVal; + }); + + electron.ipcMain.handle("capture-screenshot", async (event, rect) => { + const tabView = getWaveTabViewByWebContentsId(event.sender.id); + if (!tabView) { + throw new Error("No tab view found for the given webContents id"); + } + const image = await tabView.webContents.capturePage(rect); + const base64String = image.toPNG().toString("base64"); + return `data:image/png;base64,${base64String}`; + }); + + electron.ipcMain.on("get-env", (event, varName) => { + event.returnValue = process.env[varName] ?? null; + }); + + electron.ipcMain.on("get-about-modal-details", (event) => { + event.returnValue = getWaveVersion() as AboutModalDetails; + }); + + electron.ipcMain.on("get-zoom-factor", (event) => { + event.returnValue = event.sender.getZoomFactor(); + }); + + electron.ipcMain.on("get-is-full-screen", (event) => { + const ww = getWaveWindowByWebContentsId(event.sender.id); + event.returnValue = ww ? ww.isFullScreen() : false; + }); + + const hasBeforeInputRegisteredMap = new Map(); + + electron.ipcMain.on("webview-focus", (event: Electron.IpcMainEvent, focusedId: number) => { + webviewFocusId = focusedId; + console.log("webview-focus", focusedId); + if (focusedId == null) { + return; + } + const parentWc = event.sender; + const webviewWc = electron.webContents.fromId(focusedId); + if (webviewWc == null) { + webviewFocusId = null; + return; + } + if (!hasBeforeInputRegisteredMap.get(focusedId)) { + hasBeforeInputRegisteredMap.set(focusedId, true); + webviewWc.on("before-input-event", (e, input) => { + let waveEvent = keyutil.adaptFromElectronKeyEvent(input); + handleCtrlShiftState(parentWc, waveEvent); + if (webviewFocusId != focusedId) { + return; + } + if (input.type != "keyDown") { + return; + } + for (let keyDesc of webviewKeys) { + if (keyutil.checkKeyPressed(waveEvent, keyDesc)) { + e.preventDefault(); + parentWc.send("reinject-key", waveEvent); + console.log("webview reinject-key", keyDesc); + return; + } + } + }); + webviewWc.on("destroyed", () => { + hasBeforeInputRegisteredMap.delete(focusedId); + }); + } + }); + + electron.ipcMain.on("register-global-webview-keys", (event, keys: string[]) => { + webviewKeys = keys ?? []; + }); + + electron.ipcMain.on("set-keyboard-chord-mode", (event) => { + event.returnValue = null; + const tabView = getWaveTabViewByWebContentsId(event.sender.id); + tabView?.setKeyboardChordMode(true); + }); + + electron.ipcMain.handle("set-is-active", () => { + setWasActive(true); + }); + + const fac = new FastAverageColor(); + electron.ipcMain.on("update-window-controls-overlay", async (event, rect: Dimensions) => { + if (unamePlatform === "darwin") return; + try { + const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); + if (fullConfig?.settings?.["window:nativetitlebar"] && unamePlatform !== "win32") return; + + const zoomFactor = event.sender.getZoomFactor(); + const electronRect: Electron.Rectangle = { + x: rect.left * zoomFactor, + y: rect.top * zoomFactor, + height: rect.height * zoomFactor, + width: rect.width * zoomFactor, + }; + const overlay = await event.sender.capturePage(electronRect); + const overlayBuffer = overlay.toPNG(); + const png = PNG.sync.read(overlayBuffer); + const color = fac.prepareResult(fac.getColorFromArray4(png.data)); + const ww = getWaveWindowByWebContentsId(event.sender.id); + if (ww == null) return; + ww.setTitleBarOverlay({ + color: unamePlatform === "linux" ? color.rgba : "#00000000", + symbolColor: color.isDark ? "white" : "black", + }); + } catch (e) { + console.error("Error updating window controls overlay:", e); + } + }); + + electron.ipcMain.on("quicklook", (event, filePath: string) => { + if (unamePlatform !== "darwin") return; + child_process.execFile("/usr/bin/qlmanage", ["-p", filePath], (error, stdout, stderr) => { + if (error) { + console.error(`Error opening Quick Look: ${error}`); + } + }); + }); + + electron.ipcMain.handle("clear-webview-storage", async (event, webContentsId: number) => { + try { + const wc = electron.webContents.fromId(webContentsId); + if (wc && wc.session) { + await wc.session.clearStorageData(); + console.log("Cleared cookies and storage for webContentsId:", webContentsId); + } + } catch (e) { + console.error("Failed to clear cookies and storage:", e); + throw e; + } + }); + + electron.ipcMain.on("open-native-path", (event, filePath: string) => { + console.log("open-native-path", filePath); + filePath = filePath.replace("~", electronApp.getPath("home")); + fireAndForget(() => + callWithOriginalXdgCurrentDesktopAsync(() => + electron.shell.openPath(filePath).then((excuse) => { + if (excuse) console.error(`Failed to open ${filePath} in native application: ${excuse}`); + }) + ) + ); + }); + + electron.ipcMain.on("set-window-init-status", (event, status: "ready" | "wave-ready") => { + const tabView = getWaveTabViewByWebContentsId(event.sender.id); + if (tabView != null && tabView.initResolve != null) { + if (status === "ready") { + tabView.initResolve(); + if (tabView.savedInitOpts) { + console.log("savedInitOpts calling wave-init", tabView.waveTabId); + tabView.webContents.send("wave-init", tabView.savedInitOpts); + } + } else if (status === "wave-ready") { + tabView.waveReadyResolve(); + } + return; + } + + const builderWindow = getBuilderWindowByWebContentsId(event.sender.id); + if (builderWindow != null) { + if (status === "ready") { + if (builderWindow.savedInitOpts) { + console.log("savedInitOpts calling builder-init", builderWindow.savedInitOpts.builderId); + builderWindow.webContents.send("builder-init", builderWindow.savedInitOpts); + } + } + return; + } + + console.log("set-window-init-status: no window found for webContentsId", event.sender.id); + }); + + electron.ipcMain.on("fe-log", (event, logStr: string) => { + console.log("fe-log", logStr); + }); + + electron.ipcMain.on( + "increment-term-commands", + (event, opts?: { isRemote?: boolean; isWsl?: boolean; isDurable?: boolean }) => { + incrementTermCommandsRun(); + if (opts?.isRemote) { + incrementTermCommandsRemote(); + } + if (opts?.isWsl) { + incrementTermCommandsWsl(); + } + if (opts?.isDurable) { + incrementTermCommandsDurable(); + } + } + ); + + electron.ipcMain.on("native-paste", (event) => { + event.sender.paste(); + }); + + electron.ipcMain.on("open-builder", (event, appId?: string) => { + openBuilderWindow(appId); + }); + + electron.ipcMain.on("set-builder-window-appid", (event, appId: string) => { + const bw = getBuilderWindowByWebContentsId(event.sender.id); + if (bw == null) { + return; + } + bw.builderAppId = appId; + console.log("set-builder-window-appid", bw.builderId, appId); + }); + + electron.ipcMain.on("open-new-window", () => fireAndForget(createNewWaveWindow)); + + electron.ipcMain.on("close-builder-window", async (event) => { + const bw = getBuilderWindowByWebContentsId(event.sender.id); + if (bw == null) { + return; + } + const builderId = bw.builderId; + if (builderId) { + try { + await RpcApi.SetRTInfoCommand(ElectronWshClient, { + oref: `builder:${builderId}`, + data: {} as ObjRTInfo, + delete: true, + }); + } catch (e) { + console.error("Error deleting builder rtinfo:", e); + } + } + const wc = bw.webContents; + if (wc.isDevToolsOpened()) { + wc.closeDevTools(); + } + for (const guest of electron.webContents.getAllWebContents()) { + if (guest.getType() === "webview" && guest.hostWebContents?.id === wc.id) { + if (guest.isDevToolsOpened()) { + guest.closeDevTools(); + } + } + } + bw.destroy(); + }); + + electron.ipcMain.on("do-refresh", (event) => { + event.sender.reloadIgnoringCache(); + }); + + // ---- Directory watching for file explorer auto-update ---- + // fs.watch() must run in the main process (sandbox prevents Node in preload). + // Single channel "dir-changed" carries the path in the payload — embedding the + // renderer-supplied path in the channel name would let a compromised renderer + // collide with arbitrary channels. + const dirWatchers = new Map(); + const dirWatchersBySender = new Map>(); + + const closeWatcher = (key: string) => { + const w = dirWatchers.get(key); + if (!w) return; + try { + w.close(); + } catch { + // best-effort + } + dirWatchers.delete(key); + }; + + const releaseAllForSender = (senderId: number) => { + const keys = dirWatchersBySender.get(senderId); + if (!keys) return; + for (const k of keys) closeWatcher(k); + dirWatchersBySender.delete(senderId); + }; + + electron.ipcMain.on("watch-dir", (event, dirPath: string) => { + if (typeof dirPath !== "string" || dirPath === "") return; + const key = `${event.sender.id}:${dirPath}`; + if (dirWatchers.has(key)) return; + try { + const watcher = fs.watch(dirPath, (eventType, filename) => { + if (!event.sender.isDestroyed()) { + event.sender.send("dir-changed", dirPath, eventType, filename ?? ""); + } + }); + dirWatchers.set(key, watcher); + let set = dirWatchersBySender.get(event.sender.id); + if (!set) { + set = new Set(); + dirWatchersBySender.set(event.sender.id, set); + event.sender.once("destroyed", () => releaseAllForSender(event.sender.id)); + } + set.add(key); + } catch (_e) { + // Dir may not exist yet — silently ignore. + } + }); + + electron.ipcMain.on("unwatch-dir", (event, dirPath: string) => { + if (typeof dirPath !== "string" || dirPath === "") return; + const key = `${event.sender.id}:${dirPath}`; + closeWatcher(key); + const set = dirWatchersBySender.get(event.sender.id); + if (set) { + set.delete(key); + if (set.size === 0) dirWatchersBySender.delete(event.sender.id); + } + }); + + electron.ipcMain.handle("save-text-file", async (event, fileName: string, content: string) => { + const ww = electron.BrowserWindow.fromWebContents(event.sender); + if (ww == null) { + return false; + } + const result = await electron.dialog.showSaveDialog(ww, { + title: "Save Scrollback", + defaultPath: fileName || "session.log", + filters: [{ name: "Text Files", extensions: ["txt", "log"] }], + }); + if (result.canceled || !result.filePath) { + return false; + } + try { + await fs.promises.writeFile(result.filePath, content, "utf-8"); + console.log("saved scrollback to", result.filePath); + return true; + } catch (err) { + console.error("error saving scrollback file", err); + return false; + } + }); +} diff --git a/emain/emain-log.ts b/emain/emain-log.ts new file mode 100644 index 000000000..91241b522 --- /dev/null +++ b/emain/emain-log.ts @@ -0,0 +1,142 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "fs"; +import path from "path"; +import { format } from "util"; +import winston from "winston"; +import { getWaveDataDir, isDev } from "./emain-platform"; + +const oldConsoleLog = console.log; + +function findHighestLogNumber(logsDir: string): number { + if (!fs.existsSync(logsDir)) { + return 0; + } + const files = fs.readdirSync(logsDir); + let maxNum = 0; + for (const file of files) { + const match = file.match(/^waveapp\.(\d+)\.log$/); + if (match) { + const num = parseInt(match[1], 10); + if (num > maxNum) { + maxNum = num; + } + } + } + return maxNum; +} + +function pruneOldLogs(logsDir: string): { pruned: string[]; error: any } { + if (!fs.existsSync(logsDir)) { + return { pruned: [], error: null }; + } + + const files = fs.readdirSync(logsDir); + const logFiles: { name: string; num: number }[] = []; + + for (const file of files) { + const match = file.match(/^waveapp\.(\d+)\.log$/); + if (match) { + logFiles.push({ name: file, num: parseInt(match[1], 10) }); + } + } + + if (logFiles.length <= 5) { + return { pruned: [], error: null }; + } + + logFiles.sort((a, b) => b.num - a.num); + const toDelete = logFiles.slice(5); + const pruned: string[] = []; + let firstError: any = null; + + for (const logFile of toDelete) { + try { + fs.unlinkSync(path.join(logsDir, logFile.name)); + pruned.push(logFile.name); + } catch (e) { + if (firstError == null) { + firstError = e; + } + } + } + + return { pruned, error: firstError }; +} + +function rotateLogIfNeeded(): string | null { + const waveDataDir = getWaveDataDir(); + const logFile = path.join(waveDataDir, "waveapp.log"); + const logsDir = path.join(waveDataDir, "logs"); + + if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); + } + + if (!fs.existsSync(logFile)) { + return null; + } + + const stats = fs.statSync(logFile); + if (stats.size > 10 * 1024 * 1024) { + const nextNum = findHighestLogNumber(logsDir) + 1; + const rotatedPath = path.join(logsDir, `waveapp.${nextNum}.log`); + fs.renameSync(logFile, rotatedPath); + return rotatedPath; + } + return null; +} + +let logRotateError: any = null; +let rotatedPath: string | null = null; +let prunedFiles: string[] = []; +let pruneError: any = null; +try { + rotatedPath = rotateLogIfNeeded(); + const logsDir = path.join(getWaveDataDir(), "logs"); + const pruneResult = pruneOldLogs(logsDir); + prunedFiles = pruneResult.pruned; + pruneError = pruneResult.error; +} catch (e) { + logRotateError = e; +} + +const loggerTransports: winston.transport[] = [ + new winston.transports.File({ filename: path.join(getWaveDataDir(), "waveapp.log"), level: "info" }), +]; +if (isDev) { + loggerTransports.push(new winston.transports.Console()); +} +const loggerConfig = { + level: "info", + format: winston.format.combine( + winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss.SSS" }), + winston.format.printf((info) => `${info.timestamp} ${info.message}`) + ), + transports: loggerTransports, +}; +const logger = winston.createLogger(loggerConfig); + +function log(...msg: any[]) { + try { + logger.info(format(...msg)); + } catch (e) { + oldConsoleLog(...msg); + } +} + +if (logRotateError != null) { + log("error rotating/pruning logs (non-fatal):", logRotateError); +} +if (rotatedPath != null) { + log("rotated old log file to:", rotatedPath); +} +if (prunedFiles.length > 0) { + log("pruned old log files:", prunedFiles.join(", ")); +} +if (pruneError != null) { + log("error pruning some log files (non-fatal):", pruneError); +} + +export { log }; diff --git a/emain/emain-menu.ts b/emain/emain-menu.ts new file mode 100644 index 000000000..a819d47c6 --- /dev/null +++ b/emain/emain-menu.ts @@ -0,0 +1,529 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { waveEventSubscribeSingle } from "@/app/store/wps"; +import { RpcApi } from "@/app/store/wshclientapi"; +import * as electron from "electron"; +import { fireAndForget } from "../frontend/util/util"; +import { focusedBuilderWindow, getBuilderWindowById } from "./emain-builder"; +import { openBuilderWindow } from "./emain-ipc"; +import { isDev, unamePlatform } from "./emain-platform"; +import { clearTabCache } from "./emain-tabview"; +import { decreaseZoomLevel, increaseZoomLevel, resetZoomLevel } from "./emain-util"; +import { + createNewWaveWindow, + createWorkspace, + focusedWaveWindow, + getAllWaveWindows, + getWaveWindowByWorkspaceId, + relaunchBrowserWindows, + WaveBrowserWindow, +} from "./emain-window"; +import { ElectronWshClient } from "./emain-wsh"; +import { updater } from "./updater"; + +type AppMenuCallbacks = { + createNewWaveWindow: () => Promise; + relaunchBrowserWindows: () => Promise; +}; + +function getWindowWebContents(window: electron.BaseWindow): electron.WebContents { + if (window == null) { + return null; + } + // Check BrowserWindow first (for Tsunami Builder windows) + if (window instanceof electron.BrowserWindow) { + return window.webContents; + } + // Check WaveBrowserWindow (for main Wave windows with tab views) + if (window instanceof WaveBrowserWindow) { + if (window.activeTabView) { + return window.activeTabView.webContents; + } + return null; + } + return null; +} + +async function getWorkspaceMenu(ww?: WaveBrowserWindow): Promise { + const workspaceList = await RpcApi.WorkspaceListCommand(ElectronWshClient); + const workspaceMenu: Electron.MenuItemConstructorOptions[] = [ + { + label: "Create Workspace", + click: (_, window) => fireAndForget(() => createWorkspace((window as WaveBrowserWindow) ?? ww)), + }, + ]; + function getWorkspaceSwitchAccelerator(i: number): string { + if (i < 9) { + return unamePlatform == "darwin" ? `Command+Control+${i + 1}` : `Alt+Control+${i + 1}`; + } + } + if (workspaceList?.length) { + workspaceMenu.push( + { type: "separator" }, + ...workspaceList.map((workspace, i) => { + return { + label: `${workspace.workspacedata.name}`, + click: (_, window) => { + ((window as WaveBrowserWindow) ?? ww)?.switchWorkspace(workspace.workspacedata.oid); + }, + accelerator: getWorkspaceSwitchAccelerator(i), + }; + }) + ); + } + return workspaceMenu; +} + +function makeEditMenu(fullConfig?: FullConfigType): Electron.MenuItemConstructorOptions[] { + let pasteAccelerator: string; + if (unamePlatform === "darwin") { + pasteAccelerator = "Command+V"; + } else { + const ctrlVPaste = fullConfig?.settings?.["app:ctrlvpaste"]; + if (ctrlVPaste == null) { + pasteAccelerator = unamePlatform === "win32" ? "Control+V" : ""; + } else if (ctrlVPaste) { + pasteAccelerator = "Control+V"; + } else { + pasteAccelerator = ""; + } + } + return [ + { + role: "undo", + accelerator: unamePlatform === "darwin" ? "Command+Z" : "", + }, + { + role: "redo", + accelerator: unamePlatform === "darwin" ? "Command+Shift+Z" : "", + }, + { type: "separator" }, + { + role: "cut", + accelerator: unamePlatform === "darwin" ? "Command+X" : "", + }, + { + role: "copy", + accelerator: unamePlatform === "darwin" ? "Command+C" : "", + }, + { + role: "paste", + accelerator: pasteAccelerator, + }, + { + role: "pasteAndMatchStyle", + accelerator: unamePlatform === "darwin" ? "Command+Shift+V" : "", + }, + { + role: "delete", + }, + { + role: "selectAll", + accelerator: unamePlatform === "darwin" ? "Command+A" : "", + }, + ]; +} + +function makeFileMenu( + numWaveWindows: number, + callbacks: AppMenuCallbacks, + fullConfig: FullConfigType +): Electron.MenuItemConstructorOptions[] { + const fileMenu: Electron.MenuItemConstructorOptions[] = [ + { + label: "New Window", + accelerator: "CommandOrControl+Shift+N", + click: () => fireAndForget(callbacks.createNewWaveWindow), + }, + { + role: "close", + accelerator: "", + click: () => { + focusedWaveWindow?.close(); + }, + }, + ]; + const featureWaveAppBuilder = fullConfig?.settings?.["feature:waveappbuilder"]; + if (isDev || featureWaveAppBuilder) { + fileMenu.splice(1, 0, { + label: "New WaveApp Builder Window", + accelerator: unamePlatform === "darwin" ? "Command+Shift+B" : "Alt+Shift+B", + click: () => openBuilderWindow(""), + }); + } + if (numWaveWindows == 0) { + fileMenu.push({ + label: "New Window (hidden-1)", + accelerator: unamePlatform === "darwin" ? "Command+N" : "Alt+N", + acceleratorWorksWhenHidden: true, + visible: false, + click: () => fireAndForget(callbacks.createNewWaveWindow), + }); + fileMenu.push({ + label: "New Window (hidden-2)", + accelerator: unamePlatform === "darwin" ? "Command+T" : "Alt+T", + acceleratorWorksWhenHidden: true, + visible: false, + click: () => fireAndForget(callbacks.createNewWaveWindow), + }); + } + return fileMenu; +} + +function makeAppMenuItems(webContents: electron.WebContents): Electron.MenuItemConstructorOptions[] { + const appMenuItems: Electron.MenuItemConstructorOptions[] = [ + { + label: "About Wave Terminal", + click: (_, window) => { + (getWindowWebContents(window) ?? webContents)?.send("menu-item-about"); + }, + }, + { + label: "Check for Updates", + click: () => { + fireAndForget(() => updater?.checkForUpdates(true)); + }, + }, + { type: "separator" }, + ]; + if (unamePlatform === "darwin") { + appMenuItems.push( + { role: "services" }, + { type: "separator" }, + { role: "hide" }, + { role: "hideOthers" }, + { type: "separator" } + ); + } + appMenuItems.push({ role: "quit" }); + return appMenuItems; +} + +function makeViewMenu( + webContents: electron.WebContents, + callbacks: AppMenuCallbacks, + isBuilderWindowFocused: boolean, + fullscreenOnLaunch: boolean +): Electron.MenuItemConstructorOptions[] { + const devToolsAccel = unamePlatform === "darwin" ? "Option+Command+I" : "Alt+Shift+I"; + return [ + { + label: isBuilderWindowFocused ? "Reload Window" : "Reload Tab", + accelerator: "Shift+CommandOrControl+R", + click: (_, window) => { + (getWindowWebContents(window) ?? webContents)?.reloadIgnoringCache(); + }, + }, + { + label: "Relaunch All Windows", + click: () => callbacks.relaunchBrowserWindows(), + }, + { + label: "Clear Tab Cache", + click: () => clearTabCache(), + }, + { + label: "Toggle DevTools", + accelerator: devToolsAccel, + click: (_, window) => { + const wc = getWindowWebContents(window) ?? webContents; + wc?.toggleDevTools(); + }, + }, + { type: "separator" }, + { + label: "Reset Zoom", + accelerator: "CommandOrControl+0", + click: (_, window) => { + const wc = getWindowWebContents(window) ?? webContents; + if (wc) { + resetZoomLevel(wc); + } + }, + }, + { + label: "Zoom In", + accelerator: "CommandOrControl+=", + click: (_, window) => { + const wc = getWindowWebContents(window) ?? webContents; + if (wc) { + increaseZoomLevel(wc); + } + }, + }, + { + label: "Zoom In (hidden)", + accelerator: "CommandOrControl+Shift+=", + click: (_, window) => { + const wc = getWindowWebContents(window) ?? webContents; + if (wc) { + increaseZoomLevel(wc); + } + }, + visible: false, + acceleratorWorksWhenHidden: true, + }, + { + label: "Zoom Out", + accelerator: "CommandOrControl+-", + click: (_, window) => { + const wc = getWindowWebContents(window) ?? webContents; + if (wc) { + decreaseZoomLevel(wc); + } + }, + }, + { + label: "Zoom Out (hidden)", + accelerator: "CommandOrControl+Shift+-", + click: (_, window) => { + const wc = getWindowWebContents(window) ?? webContents; + if (wc) { + decreaseZoomLevel(wc); + } + }, + visible: false, + acceleratorWorksWhenHidden: true, + }, + { + label: "Launch On Full Screen", + submenu: [ + { + label: "On", + type: "radio", + checked: fullscreenOnLaunch, + click: () => { + RpcApi.SetConfigCommand(ElectronWshClient, { "window:fullscreenonlaunch": true }); + }, + }, + { + label: "Off", + type: "radio", + checked: !fullscreenOnLaunch, + click: () => { + RpcApi.SetConfigCommand(ElectronWshClient, { "window:fullscreenonlaunch": false }); + }, + }, + ], + }, + { type: "separator" }, + { + role: "togglefullscreen", + }, + { type: "separator" }, + { + label: "Toggle Widgets Bar", + click: () => { + fireAndForget(async () => { + const workspaceId = focusedWaveWindow?.workspaceId; + if (!workspaceId) return; + const oref = `workspace:${workspaceId}`; + const meta = await RpcApi.GetMetaCommand(ElectronWshClient, { oref }); + const current = meta?.["layout:widgetsvisible"] ?? true; + await RpcApi.SetMetaCommand(ElectronWshClient, { oref, meta: { "layout:widgetsvisible": !current } }); + }); + }, + }, + ]; +} + +async function makeFullAppMenu(callbacks: AppMenuCallbacks, workspaceOrBuilderId?: string): Promise { + const numWaveWindows = getAllWaveWindows().length; + const webContents = workspaceOrBuilderId && getWebContentsByWorkspaceOrBuilderId(workspaceOrBuilderId); + const appMenuItems = makeAppMenuItems(webContents); + + const isBuilderWindowFocused = focusedBuilderWindow != null; + let fullscreenOnLaunch = false; + let fullConfig: FullConfigType = null; + try { + fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); + fullscreenOnLaunch = fullConfig?.settings["window:fullscreenonlaunch"]; + } catch (e) { + console.error("Error fetching config:", e); + } + const editMenu = makeEditMenu(fullConfig); + const fileMenu = makeFileMenu(numWaveWindows, callbacks, fullConfig); + const viewMenu = makeViewMenu(webContents, callbacks, isBuilderWindowFocused, fullscreenOnLaunch); + let workspaceMenu: Electron.MenuItemConstructorOptions[] = null; + try { + workspaceMenu = await getWorkspaceMenu(); + } catch (e) { + console.error("getWorkspaceMenu error:", e); + } + const windowMenu: Electron.MenuItemConstructorOptions[] = [ + { role: "minimize", accelerator: "" }, + { role: "zoom" }, + { type: "separator" }, + { role: "front" }, + ]; + const menuTemplate: Electron.MenuItemConstructorOptions[] = [ + { role: "appMenu", submenu: appMenuItems }, + { role: "fileMenu", submenu: fileMenu }, + { role: "editMenu", submenu: editMenu }, + { role: "viewMenu", submenu: viewMenu }, + ]; + if (workspaceMenu != null && !isBuilderWindowFocused) { + menuTemplate.push({ + label: "Workspace", + id: "workspace-menu", + submenu: workspaceMenu, + }); + } + menuTemplate.push({ + role: "windowMenu", + submenu: windowMenu, + }); + return electron.Menu.buildFromTemplate(menuTemplate); +} + +export function instantiateAppMenu(workspaceOrBuilderId?: string): Promise { + return makeFullAppMenu( + { + createNewWaveWindow, + relaunchBrowserWindows, + }, + workspaceOrBuilderId + ); +} + +// does not a set a menu on windows +export function makeAndSetAppMenu() { + if (unamePlatform === "win32") { + return; + } + fireAndForget(async () => { + const menu = await instantiateAppMenu(); + electron.Menu.setApplicationMenu(menu); + }); +} + +function initMenuEventSubscriptions() { + waveEventSubscribeSingle({ + eventType: "workspace:update", + handler: makeAndSetAppMenu, + }); +} + +function getWebContentsByWorkspaceOrBuilderId(workspaceOrBuilderId: string): electron.WebContents { + const ww = getWaveWindowByWorkspaceId(workspaceOrBuilderId); + if (ww) { + return ww.activeTabView?.webContents; + } + + const bw = getBuilderWindowById(workspaceOrBuilderId); + if (bw) { + return bw.webContents; + } + + return null; +} + +function convertMenuDefArrToMenu( + webContents: electron.WebContents, + menuDefArr: ElectronContextMenuItem[], + menuState: { hasClick: boolean } +): electron.Menu { + const menuItems: electron.MenuItem[] = []; + for (const menuDef of menuDefArr) { + const menuItemTemplate: electron.MenuItemConstructorOptions = { + role: menuDef.role as any, + label: menuDef.label, + type: menuDef.type, + click: () => { + menuState.hasClick = true; + webContents.send("contextmenu-click", menuDef.id); + }, + checked: menuDef.checked, + enabled: menuDef.enabled, + }; + if (menuDef.submenu != null) { + menuItemTemplate.submenu = convertMenuDefArrToMenu(webContents, menuDef.submenu, menuState); + } + const menuItem = new electron.MenuItem(menuItemTemplate); + menuItems.push(menuItem); + } + return electron.Menu.buildFromTemplate(menuItems); +} + +electron.ipcMain.on( + "contextmenu-show", + (event, workspaceOrBuilderId: string, menuDefArr: ElectronContextMenuItem[], position?: { x: number; y: number }) => { + const webContents = getWebContentsByWorkspaceOrBuilderId(workspaceOrBuilderId); + if (!webContents) { + console.error("invalid window for context menu:", workspaceOrBuilderId); + event.returnValue = true; + return; + } + if (menuDefArr.length === 0) { + webContents.send("contextmenu-click", null); + event.returnValue = true; + return; + } + fireAndForget(async () => { + const menuState = { hasClick: false }; + const menu = convertMenuDefArrToMenu(webContents, menuDefArr, menuState); + const popupOpts: Electron.PopupOptions = { + callback: () => { + if (!menuState.hasClick) { + webContents.send("contextmenu-click", null); + } + }, + }; + if (position && position.x != null && position.y != null) { + const browserWindow = electron.BrowserWindow.fromWebContents(webContents); + if (browserWindow) { + popupOpts.window = browserWindow; + } + const zoom = webContents.getZoomFactor() || 1; + popupOpts.x = Math.round(position.x * zoom); + popupOpts.y = Math.round(position.y * zoom); + } + menu.popup(popupOpts); + }); + event.returnValue = true; + } +); + +electron.ipcMain.on("workspace-appmenu-show", (event, workspaceId: string) => { + fireAndForget(async () => { + const webContents = getWebContentsByWorkspaceOrBuilderId(workspaceId); + if (!webContents) { + console.error("invalid window for workspace app menu:", workspaceId); + return; + } + const menu = await instantiateAppMenu(workspaceId); + menu.popup(); + }); + event.returnValue = true; +}); + +electron.ipcMain.on("builder-appmenu-show", (event, builderId: string) => { + fireAndForget(async () => { + const webContents = getWebContentsByWorkspaceOrBuilderId(builderId); + if (!webContents) { + console.error("invalid window for builder app menu:", builderId); + return; + } + const menu = await instantiateAppMenu(builderId); + menu.popup(); + }); + event.returnValue = true; +}); + +const dockMenu = electron.Menu.buildFromTemplate([ + { + label: "New Window", + click() { + fireAndForget(createNewWaveWindow); + }, + }, +]); + +function makeDockTaskbar() { + if (unamePlatform == "darwin") { + electron.app.dock.setMenu(dockMenu); + } +} + +export { initMenuEventSubscriptions, makeDockTaskbar }; diff --git a/emain/emain-platform.ts b/emain/emain-platform.ts new file mode 100644 index 000000000..eee163838 --- /dev/null +++ b/emain/emain-platform.ts @@ -0,0 +1,286 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { fireAndForget } from "@/util/util"; +import { app, dialog, ipcMain, shell } from "electron"; +import envPaths from "env-paths"; +import { existsSync, mkdirSync } from "fs"; +import os from "os"; +import path from "path"; +import { WaveDevVarName, WaveDevViteVarName } from "../frontend/util/isdev"; +import * as keyutil from "../frontend/util/keyutil"; + +// This is a little trick to ensure that Electron puts all its runtime data into a subdirectory to avoid conflicts with our own data. +// On macOS, it will store to ~/Library/Application \Support/crest/electron +// On Linux, it will store to ~/.config/crest/electron +// On Windows, it will store to %LOCALAPPDATA%/crest/electron +app.setName("crest/electron"); + +const isDev = !app.isPackaged; +const isDevVite = isDev && process.env.ELECTRON_RENDERER_URL; +console.log(`Running in ${isDev ? "development" : "production"} mode`); +if (isDev) { + process.env[WaveDevVarName] = "1"; +} +if (isDevVite) { + process.env[WaveDevViteVarName] = "1"; +} + +const waveDirNamePrefix = "crest"; +const waveDirNameSuffix = isDev ? "dev" : ""; +const waveDirName = `${waveDirNamePrefix}${waveDirNameSuffix ? `-${waveDirNameSuffix}` : ""}`; + +const paths = envPaths("crest", { suffix: waveDirNameSuffix }); + +app.setName(isDev ? "Crest (Dev)" : "Crest"); +const unamePlatform = process.platform; +const unameArch: string = process.arch; +keyutil.setKeyUtilPlatform(unamePlatform); + +const WaveConfigHomeVarName = "WAVETERM_CONFIG_HOME"; +const WaveDataHomeVarName = "WAVETERM_DATA_HOME"; +const WaveHomeVarName = "WAVETERM_HOME"; + +export function checkIfRunningUnderARM64Translation(fullConfig: FullConfigType) { + if (!fullConfig.settings["app:dismissarchitecturewarning"] && app.runningUnderARM64Translation) { + console.log("Running under ARM64 translation, alerting user"); + const dialogOpts: Electron.MessageBoxOptions = { + type: "warning", + buttons: ["Dismiss", "Learn More"], + title: "Crest has detected a performance issue", + message: `Crest is running in ARM64 translation mode which may impact performance.\n\nRecommendation: Download the native ARM64 version from our website for optimal performance.`, + }; + + const choice = dialog.showMessageBoxSync(null, dialogOpts); + if (choice === 1) { + // Open the documentation URL + console.log("User chose to learn more"); + fireAndForget(() => + shell.openExternal( + "https://docs.waveterm.dev/faq#why-does-wave-warn-me-about-arm64-translation-when-it-launches" + ) + ); + throw new Error("User redirected to docsite to learn more about ARM64 translation, exiting"); + } else { + console.log("User dismissed the dialog"); + } + } +} + +/** + * Gets the path to the old Wave home directory (defaults to `~/.waveterm`). + * @returns The path to the directory if it exists and contains valid data for the current app, otherwise null. + */ +function getWaveHomeDir(): string { + let home = process.env[WaveHomeVarName]; + if (!home) { + const homeDir = app.getPath("home"); + if (homeDir) { + home = path.join(homeDir, `.${waveDirName}`); + } + } + // If home exists and it has `wave.lock` in it, we know it has valid data from Wave >=v0.8. Otherwise, it could be for WaveLegacy ( { + event.returnValue = isDev; +}); +ipcMain.on("get-platform", (event, url) => { + event.returnValue = unamePlatform; +}); +ipcMain.on("get-user-name", (event) => { + const userInfo = os.userInfo(); + event.returnValue = userInfo.username; +}); +ipcMain.on("get-host-name", (event) => { + event.returnValue = os.hostname(); +}); +ipcMain.on("get-webview-preload", (event) => { + event.returnValue = path.join(getElectronAppBasePath(), "preload", "preload-webview.cjs"); +}); +ipcMain.on("get-data-dir", (event) => { + event.returnValue = getWaveDataDir(); +}); +ipcMain.on("get-config-dir", (event) => { + event.returnValue = getWaveConfigDir(); +}); +ipcMain.on("get-home-dir", (event) => { + event.returnValue = app.getPath("home"); +}); + +/** + * Gets the value of the XDG_CURRENT_DESKTOP environment variable. If ORIGINAL_XDG_CURRENT_DESKTOP is set, it will be returned instead. + * This corrects for a strange behavior in Electron, where it sets its own value for XDG_CURRENT_DESKTOP to improve Chromium compatibility. + * @see https://www.electronjs.org/docs/latest/api/environment-variables#original_xdg_current_desktop + * @returns The value of the XDG_CURRENT_DESKTOP environment variable, or ORIGINAL_XDG_CURRENT_DESKTOP if set, or undefined if neither are set. + */ +function getXdgCurrentDesktop(): string { + if (process.env.ORIGINAL_XDG_CURRENT_DESKTOP) { + return process.env.ORIGINAL_XDG_CURRENT_DESKTOP; + } else if (process.env.XDG_CURRENT_DESKTOP) { + return process.env.XDG_CURRENT_DESKTOP; + } else { + return undefined; + } +} + +/** + * Calls the given callback with the value of the XDG_CURRENT_DESKTOP environment variable set to ORIGINAL_XDG_CURRENT_DESKTOP if it is set. + * @see https://www.electronjs.org/docs/latest/api/environment-variables#original_xdg_current_desktop + * @param callback The callback to call. + */ +function callWithOriginalXdgCurrentDesktop(callback: () => void) { + const currXdgCurrentDesktopDefined = "XDG_CURRENT_DESKTOP" in process.env; + const currXdgCurrentDesktop = process.env.XDG_CURRENT_DESKTOP; + const originalXdgCurrentDesktop = getXdgCurrentDesktop(); + if (originalXdgCurrentDesktop) { + process.env.XDG_CURRENT_DESKTOP = originalXdgCurrentDesktop; + } + callback(); + if (originalXdgCurrentDesktop) { + if (currXdgCurrentDesktopDefined) { + process.env.XDG_CURRENT_DESKTOP = currXdgCurrentDesktop; + } else { + delete process.env.XDG_CURRENT_DESKTOP; + } + } +} + +/** + * Calls the given async callback with the value of the XDG_CURRENT_DESKTOP environment variable set to ORIGINAL_XDG_CURRENT_DESKTOP if it is set. + * @see https://www.electronjs.org/docs/latest/api/environment-variables#original_xdg_current_desktop + * @param callback The async callback to call. + */ +async function callWithOriginalXdgCurrentDesktopAsync(callback: () => Promise) { + const currXdgCurrentDesktopDefined = "XDG_CURRENT_DESKTOP" in process.env; + const currXdgCurrentDesktop = process.env.XDG_CURRENT_DESKTOP; + const originalXdgCurrentDesktop = getXdgCurrentDesktop(); + if (originalXdgCurrentDesktop) { + process.env.XDG_CURRENT_DESKTOP = originalXdgCurrentDesktop; + } + await callback(); + if (originalXdgCurrentDesktop) { + if (currXdgCurrentDesktopDefined) { + process.env.XDG_CURRENT_DESKTOP = currXdgCurrentDesktop; + } else { + delete process.env.XDG_CURRENT_DESKTOP; + } + } +} + +export { + callWithOriginalXdgCurrentDesktop, + callWithOriginalXdgCurrentDesktopAsync, + getElectronAppBasePath, + getElectronAppResourcesPath, + getElectronAppUnpackedBasePath, + getWaveConfigDir, + getWaveDataDir, + getWaveSrvCwd, + getWaveSrvPath, + getXdgCurrentDesktop, + isDev, + isDevVite, + unameArch, + unamePlatform, + WaveConfigHomeVarName, + WaveDataHomeVarName, +}; diff --git a/emain/emain-tabview.ts b/emain/emain-tabview.ts new file mode 100644 index 000000000..949655d2a --- /dev/null +++ b/emain/emain-tabview.ts @@ -0,0 +1,401 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { RpcApi } from "@/app/store/wshclientapi"; +import { adaptFromElectronKeyEvent, checkKeyPressed } from "@/util/keyutil"; +import { CHORD_TIMEOUT } from "@/util/sharedconst"; +import { Rectangle, shell, WebContentsView } from "electron"; +import { createNewWaveWindow, getWaveWindowById } from "emain/emain-window"; +import path from "path"; +import { configureAuthKeyRequestInjection } from "./authkey"; +import { setWasActive } from "./emain-activity"; +import { getElectronAppBasePath, isDevVite, unamePlatform } from "./emain-platform"; +import { + decreaseZoomLevel, + handleCtrlShiftFocus, + handleCtrlShiftState, + increaseZoomLevel, + resetZoomLevel, + shFrameNavHandler, + shNavHandler, +} from "./emain-util"; +import { ElectronWshClient } from "./emain-wsh"; + +function handleWindowsMenuAccelerators( + waveEvent: WaveKeyboardEvent, + tabView: WaveTabView, + fullConfig: FullConfigType +): boolean { + const waveWindow = getWaveWindowById(tabView.waveWindowId); + + if (checkKeyPressed(waveEvent, "Ctrl:Shift:n")) { + createNewWaveWindow(); + return true; + } + + if (checkKeyPressed(waveEvent, "Ctrl:Shift:r")) { + tabView.webContents.reloadIgnoringCache(); + return true; + } + + if (checkKeyPressed(waveEvent, "Ctrl:v")) { + const ctrlVPaste = fullConfig?.settings?.["app:ctrlvpaste"]; + const shouldPaste = ctrlVPaste ?? true; + if (!shouldPaste) { + return false; + } + tabView.webContents.paste(); + return true; + } + + if (checkKeyPressed(waveEvent, "Ctrl:0")) { + resetZoomLevel(tabView.webContents); + return true; + } + + if (checkKeyPressed(waveEvent, "Ctrl:=") || checkKeyPressed(waveEvent, "Ctrl:Shift:=")) { + increaseZoomLevel(tabView.webContents); + return true; + } + + if (checkKeyPressed(waveEvent, "Ctrl:-") || checkKeyPressed(waveEvent, "Ctrl:Shift:-")) { + decreaseZoomLevel(tabView.webContents); + return true; + } + + if (checkKeyPressed(waveEvent, "F11")) { + if (waveWindow) { + waveWindow.setFullScreen(!waveWindow.isFullScreen()); + } + return true; + } + + for (let i = 1; i <= 9; i++) { + if (checkKeyPressed(waveEvent, `Alt:Ctrl:${i}`)) { + const workspaceNum = i - 1; + RpcApi.WorkspaceListCommand(ElectronWshClient).then((workspaceList) => { + if (workspaceList && workspaceNum < workspaceList.length) { + const workspace = workspaceList[workspaceNum]; + if (waveWindow) { + waveWindow.switchWorkspace(workspace.workspacedata.oid); + } + } + }); + return true; + } + } + + if (checkKeyPressed(waveEvent, "Alt:Shift:i")) { + tabView.webContents.toggleDevTools(); + return true; + } + + return false; +} + +function computeBgColor(fullConfig: FullConfigType): string { + const settings = fullConfig?.settings; + const isTransparent = settings?.["window:transparent"] ?? false; + const isBlur = !isTransparent && (settings?.["window:blur"] ?? false); + if (isTransparent) { + return "#00000000"; + } else if (isBlur) { + return "#00000000"; + } else { + return "#222222"; + } +} + +const wcIdToWaveTabMap = new Map(); + +export function getWaveTabViewByWebContentsId(webContentsId: number): WaveTabView { + if (webContentsId == null) { + return null; + } + return wcIdToWaveTabMap.get(webContentsId); +} + +export class WaveTabView extends WebContentsView { + waveWindowId: string; // this will be set for any tabviews that are initialized. (unset for the hot spare) + isActiveTab: boolean; + isWaveAIOpen: boolean; + private _waveTabId: string; // always set, WaveTabViews are unique per tab + lastUsedTs: number; // ts milliseconds + createdTs: number; // ts milliseconds + initPromise: Promise; + initResolve: () => void; + savedInitOpts: WaveInitOpts; + waveReadyPromise: Promise; + waveReadyResolve: () => void; + isInitialized: boolean = false; + isWaveReady: boolean = false; + isDestroyed: boolean = false; + keyboardChordMode: boolean = false; + resetChordModeTimeout: NodeJS.Timeout = null; + + constructor(fullConfig: FullConfigType) { + console.log("createBareTabView"); + super({ + webPreferences: { + preload: path.join(getElectronAppBasePath(), "preload", "index.cjs"), + webviewTag: true, + }, + }); + // Without this, Electron paints the WebContentsView with its + // default white background before the renderer's CSS applies. + // On new-tab creation that showed up as the "whole screen + // flashes white" moment. Match the main BrowserWindow's + // backgroundColor so it blends into the app shell instead. + this.setBackgroundColor("#222222"); + this.createdTs = Date.now(); + this.isWaveAIOpen = false; + this.savedInitOpts = null; + this.initPromise = new Promise((resolve, _) => { + this.initResolve = resolve; + }); + this.initPromise.then(() => { + this.isInitialized = true; + console.log("tabview init", Date.now() - this.createdTs + "ms"); + }); + this.waveReadyPromise = new Promise((resolve, _) => { + this.waveReadyResolve = resolve; + }); + this.waveReadyPromise.then(() => { + this.isWaveReady = true; + }); + const wcId = this.webContents.id; + wcIdToWaveTabMap.set(wcId, this); + if (isDevVite) { + this.webContents.loadURL(`${process.env.ELECTRON_RENDERER_URL}/index.html`); + } else { + this.webContents.loadFile(path.join(getElectronAppBasePath(), "frontend", "index.html")); + } + this.webContents.on("destroyed", () => { + wcIdToWaveTabMap.delete(wcId); + removeWaveTabView(this.waveTabId); + this.isDestroyed = true; + }); + this.setBackgroundColor(computeBgColor(fullConfig)); + } + + get waveTabId(): string { + return this._waveTabId; + } + + set waveTabId(waveTabId: string) { + this._waveTabId = waveTabId; + } + + setKeyboardChordMode(mode: boolean) { + this.keyboardChordMode = mode; + if (mode) { + if (this.resetChordModeTimeout) { + clearTimeout(this.resetChordModeTimeout); + } + this.resetChordModeTimeout = setTimeout(() => { + this.keyboardChordMode = false; + }, CHORD_TIMEOUT); + } else { + if (this.resetChordModeTimeout) { + clearTimeout(this.resetChordModeTimeout); + this.resetChordModeTimeout = null; + } + } + } + + positionTabOnScreen(winBounds: Rectangle) { + const curBounds = this.getBounds(); + if ( + curBounds.width == winBounds.width && + curBounds.height == winBounds.height && + curBounds.x == 0 && + curBounds.y == 0 + ) { + return; + } + this.setBounds({ x: 0, y: 0, width: winBounds.width, height: winBounds.height }); + } + + positionTabOffScreen(winBounds: Rectangle) { + this.setBounds({ + x: -15000, + y: -15000, + width: winBounds.width, + height: winBounds.height, + }); + } + + isOnScreen() { + const bounds = this.getBounds(); + return bounds.x == 0 && bounds.y == 0; + } + + destroy() { + console.log("destroy tab", this.waveTabId); + removeWaveTabView(this.waveTabId); + if (!this.isDestroyed) { + this.webContents?.close(); + } + this.isDestroyed = true; + } +} + +let MaxCacheSize = 10; +const wcvCache = new Map(); + +export function setMaxTabCacheSize(size: number) { + console.log("setMaxTabCacheSize", size); + MaxCacheSize = size; +} + +export function getWaveTabView(waveTabId: string): WaveTabView | undefined { + const rtn = wcvCache.get(waveTabId); + if (rtn) { + rtn.lastUsedTs = Date.now(); + } + return rtn; +} + +function tryEvictEntry(waveTabId: string): boolean { + const tabView = wcvCache.get(waveTabId); + if (!tabView) { + return false; + } + if (tabView.isActiveTab) { + return false; + } + const lastUsedDiff = Date.now() - tabView.lastUsedTs; + if (lastUsedDiff < 1000) { + return false; + } + const ww = getWaveWindowById(tabView.waveWindowId); + if (!ww) { + // this shouldn't happen, but if it does, just destroy the tabview + console.log("[error] WaveWindow not found for WaveTabView", tabView.waveTabId); + tabView.destroy(); + return true; + } else { + // will trigger a destroy on the tabview + ww.removeTabView(tabView.waveTabId, false); + return true; + } +} + +function checkAndEvictCache(): void { + if (wcvCache.size <= MaxCacheSize) { + return; + } + const sorted = Array.from(wcvCache.values()).sort((a, b) => { + // Prioritize entries which are active + if (a.isActiveTab && !b.isActiveTab) { + return -1; + } + // Otherwise, sort by lastUsedTs + return a.lastUsedTs - b.lastUsedTs; + }); + for (let i = 0; i < sorted.length - MaxCacheSize; i++) { + tryEvictEntry(sorted[i].waveTabId); + } +} + +export function clearTabCache() { + const wcVals = Array.from(wcvCache.values()); + for (let i = 0; i < wcVals.length; i++) { + const tabView = wcVals[i]; + tryEvictEntry(tabView.waveTabId); + } +} + +// returns [tabview, initialized] +export async function getOrCreateWebViewForTab(waveWindowId: string, tabId: string): Promise<[WaveTabView, boolean]> { + let tabView = getWaveTabView(tabId); + if (tabView) { + return [tabView, true]; + } + const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); + tabView = getSpareTab(fullConfig); + tabView.waveWindowId = waveWindowId; + tabView.lastUsedTs = Date.now(); + setWaveTabView(tabId, tabView); + tabView.waveTabId = tabId; + tabView.webContents.on("will-navigate", shNavHandler); + tabView.webContents.on("will-frame-navigate", shFrameNavHandler); + tabView.webContents.on("did-attach-webview", (event, wc) => { + wc.setWindowOpenHandler((details) => { + if (wc == null || wc.isDestroyed() || tabView.webContents == null || tabView.webContents.isDestroyed()) { + return { action: "deny" }; + } + tabView.webContents.send("webview-new-window", wc.id, details); + return { action: "deny" }; + }); + }); + tabView.webContents.on("before-input-event", (e, input) => { + const waveEvent = adaptFromElectronKeyEvent(input); + // console.log("WIN bie", tabView.waveTabId.substring(0, 8), waveEvent.type, waveEvent.code); + handleCtrlShiftState(tabView.webContents, waveEvent); + setWasActive(true); + if (input.type == "keyDown" && tabView.keyboardChordMode) { + e.preventDefault(); + tabView.setKeyboardChordMode(false); + tabView.webContents.send("reinject-key", waveEvent); + return; + } + + if (unamePlatform === "win32" && input.type == "keyDown") { + if (handleWindowsMenuAccelerators(waveEvent, tabView, fullConfig)) { + e.preventDefault(); + return; + } + } + }); + tabView.webContents.setWindowOpenHandler(({ url, frameName }) => { + if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("file://")) { + console.log("openExternal fallback", url); + shell.openExternal(url); + } + console.log("window-open denied", url); + return { action: "deny" }; + }); + tabView.webContents.on("blur", () => { + handleCtrlShiftFocus(tabView.webContents, false); + }); + configureAuthKeyRequestInjection(tabView.webContents.session); + return [tabView, false]; +} + +export function setWaveTabView(waveTabId: string, wcv: WaveTabView): void { + if (waveTabId == null) { + return; + } + wcvCache.set(waveTabId, wcv); + checkAndEvictCache(); +} + +function removeWaveTabView(waveTabId: string): void { + if (waveTabId == null) { + return; + } + wcvCache.delete(waveTabId); +} + +let HotSpareTab: WaveTabView = null; + +export function ensureHotSpareTab(fullConfig: FullConfigType) { + console.log("ensureHotSpareTab"); + if (HotSpareTab == null) { + HotSpareTab = new WaveTabView(fullConfig); + } +} + +export function getSpareTab(fullConfig: FullConfigType): WaveTabView { + setTimeout(() => ensureHotSpareTab(fullConfig), 500); + if (HotSpareTab != null) { + const rtn = HotSpareTab; + HotSpareTab = null; + console.log("getSpareTab: returning hotspare"); + return rtn; + } else { + console.log("getSpareTab: creating new tab"); + return new WaveTabView(fullConfig); + } +} diff --git a/emain/emain-util.ts b/emain/emain-util.ts new file mode 100644 index 000000000..88933ca8f --- /dev/null +++ b/emain/emain-util.ts @@ -0,0 +1,352 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as electron from "electron"; +import { getWebServerEndpoint } from "../frontend/util/endpoints"; + +export const WaveAppPathVarName = "WAVETERM_APP_PATH"; +export const WaveAppResourcesPathVarName = "WAVETERM_RESOURCES_PATH"; +export const WaveAppElectronExecPath = "WAVETERM_ELECTRONEXECPATH"; + +const MinZoomLevel = 0.4; +const MaxZoomLevel = 2.6; +const ZoomDelta = 0.2; + +// Note: Chromium automatically syncs zoom factor across all WebContents +// sharing the same origin/session, so we only need to notify renderers +// to update their CSS/state — not call setZoomFactor on each one. +// We broadcast to all WebContents (including devtools, webviews, etc.) but +// that is safe because "zoom-factor-change" is a custom app-defined event +// that only our renderers listen to; unrecognized IPC messages are ignored. +function broadcastZoomFactorChanged(newZoomFactor: number): void { + for (const wc of electron.webContents.getAllWebContents()) { + if (wc.isDestroyed()) { + continue; + } + wc.send("zoom-factor-change", newZoomFactor); + } +} + +export function increaseZoomLevel(webContents: electron.WebContents): void { + const newZoom = Math.min(MaxZoomLevel, webContents.getZoomFactor() + ZoomDelta); + webContents.setZoomFactor(newZoom); + broadcastZoomFactorChanged(newZoom); +} + +export function decreaseZoomLevel(webContents: electron.WebContents): void { + const newZoom = Math.max(MinZoomLevel, webContents.getZoomFactor() - ZoomDelta); + webContents.setZoomFactor(newZoom); + broadcastZoomFactorChanged(newZoom); +} + +export function resetZoomLevel(webContents: electron.WebContents): void { + webContents.setZoomFactor(1); + broadcastZoomFactorChanged(1); +} + +export function getElectronExecPath(): string { + return process.execPath; +} + +// not necessarily exact, but we use this to help get us unstuck in certain cases +let lastCtrlShiftSate: boolean = false; + +export function delay(ms): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function setCtrlShift(wc: Electron.WebContents, state: boolean) { + lastCtrlShiftSate = state; + wc.send("control-shift-state-update", state); +} + +export function handleCtrlShiftFocus(sender: Electron.WebContents, focused: boolean) { + if (!focused) { + setCtrlShift(sender, false); + } +} + +export function handleCtrlShiftState(sender: Electron.WebContents, waveEvent: WaveKeyboardEvent) { + if (waveEvent.type == "keyup") { + if (waveEvent.key === "Control" || waveEvent.key === "Shift") { + setCtrlShift(sender, false); + } + if (waveEvent.key == "Meta") { + if (waveEvent.control && waveEvent.shift) { + setCtrlShift(sender, true); + } + } + if (lastCtrlShiftSate) { + if (!waveEvent.control || !waveEvent.shift) { + setCtrlShift(sender, false); + } + } + return; + } + if (waveEvent.type == "keydown") { + if (waveEvent.key === "Control" || waveEvent.key === "Shift" || waveEvent.key === "Meta") { + if (waveEvent.control && waveEvent.shift && !waveEvent.meta) { + // Set the control and shift without the Meta key + setCtrlShift(sender, true); + } else { + // Unset if Meta is pressed + setCtrlShift(sender, false); + } + } + return; + } +} + +export function shNavHandler(event: Electron.Event, url: string) { + const isDev = !electron.app.isPackaged; + if ( + isDev && + (url.startsWith("http://127.0.0.1:5173/index.html") || + url.startsWith("http://localhost:5173/index.html") || + url.startsWith("http://127.0.0.1:5174/index.html") || + url.startsWith("http://localhost:5174/index.html")) + ) { + // this is a dev-mode hot-reload, ignore it + console.log("allowing hot-reload of index.html"); + return; + } + event.preventDefault(); + if (url.startsWith("https://") || url.startsWith("http://") || url.startsWith("file://")) { + console.log("open external, shNav", url); + electron.shell.openExternal(url); + } else { + console.log("navigation canceled", url); + } +} + +function frameOrAncestorHasName(frame: Electron.WebFrameMain, name: string): boolean { + let cur: Electron.WebFrameMain = frame; + while (cur != null) { + if (cur.name === name) { + return true; + } + cur = cur.parent; + } + return false; +} + +export function shFrameNavHandler(event: Electron.Event) { + if (!event.frame?.parent) { + // only use this handler to process iframe events (non-iframe events go to shNavHandler) + return; + } + const url = event.url; + console.log(`frame-navigation url=${url} frame=${event.frame.name}`); + if (event.frame.name == "webview") { + // "webview" links always open in new window + // this will *not* effect the initial load because srcdoc does not count as an electron navigation + console.log("open external, frameNav", url); + event.preventDefault(); + electron.shell.openExternal(url); + return; + } + if ( + frameOrAncestorHasName(event.frame, "pdfview") && + (url.startsWith("blob:file:///") || + url.startsWith("chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai/") || + url.startsWith(getWebServerEndpoint() + "/wave/stream-file?") || + url.startsWith(getWebServerEndpoint() + "/wave/stream-file/") || + url.startsWith(getWebServerEndpoint() + "/wave/stream-local-file?")) + ) { + // allowed + return; + } + if (event.frame.name != null && event.frame.name.startsWith("tsunami:")) { + // Parse port from frame name: tsunami:[port]:[blockid] + const nameParts = event.frame.name.split(":"); + const expectedPort = nameParts.length >= 2 ? nameParts[1] : null; + + try { + const tsunamiUrl = new URL(url); + if ( + tsunamiUrl.protocol === "http:" && + tsunamiUrl.hostname === "localhost" && + expectedPort && + tsunamiUrl.port === expectedPort + ) { + // allowed + return; + } + // If navigation is not to expected port, open externally + event.preventDefault(); + electron.shell.openExternal(url); + return; + } catch (e) { + // Invalid URL, fall through to prevent navigation + } + } + event.preventDefault(); + console.log("frame navigation canceled", event.frame.name, url); +} + +function isWindowFullyVisible(bounds: electron.Rectangle): boolean { + const displays = electron.screen.getAllDisplays(); + + // Helper function to check if a point is inside any display + function isPointInDisplay(x: number, y: number) { + for (const display of displays) { + const { x: dx, y: dy, width, height } = display.bounds; + if (x >= dx && x < dx + width && y >= dy && y < dy + height) { + return true; + } + } + return false; + } + + // Check all corners of the window + const topLeft = isPointInDisplay(bounds.x, bounds.y); + const topRight = isPointInDisplay(bounds.x + bounds.width, bounds.y); + const bottomLeft = isPointInDisplay(bounds.x, bounds.y + bounds.height); + const bottomRight = isPointInDisplay(bounds.x + bounds.width, bounds.y + bounds.height); + + return topLeft && topRight && bottomLeft && bottomRight; +} + +function findDisplayWithMostArea(bounds: electron.Rectangle): electron.Display { + const displays = electron.screen.getAllDisplays(); + let maxArea = 0; + let bestDisplay = null; + + for (let display of displays) { + const { x, y, width, height } = display.bounds; + const overlapX = Math.max(0, Math.min(bounds.x + bounds.width, x + width) - Math.max(bounds.x, x)); + const overlapY = Math.max(0, Math.min(bounds.y + bounds.height, y + height) - Math.max(bounds.y, y)); + const overlapArea = overlapX * overlapY; + + if (overlapArea > maxArea) { + maxArea = overlapArea; + bestDisplay = display; + } + } + + return bestDisplay; +} + +function adjustBoundsToFitDisplay(bounds: electron.Rectangle, display: electron.Display): electron.Rectangle { + const { x: dx, y: dy, width: dWidth, height: dHeight } = display.workArea; + let { x, y, width, height } = bounds; + + // Adjust width and height to fit within the display's work area + width = Math.min(width, dWidth); + height = Math.min(height, dHeight); + + // Adjust x to ensure the window fits within the display + if (x < dx) { + x = dx; + } else if (x + width > dx + dWidth) { + x = dx + dWidth - width; + } + + // Adjust y to ensure the window fits within the display + if (y < dy) { + y = dy; + } else if (y + height > dy + dHeight) { + y = dy + dHeight - height; + } + return { x, y, width, height }; +} + +export function ensureBoundsAreVisible(bounds: electron.Rectangle): electron.Rectangle { + if (!isWindowFullyVisible(bounds)) { + let targetDisplay = findDisplayWithMostArea(bounds); + + if (!targetDisplay) { + targetDisplay = electron.screen.getPrimaryDisplay(); + } + + return adjustBoundsToFitDisplay(bounds, targetDisplay); + } + return bounds; +} + +export function waveKeyToElectronKey(waveKey: string): string { + const waveParts = waveKey.split(":"); + const electronParts: Array = waveParts.map((part: string) => { + const digitRegexpMatch = new RegExp("^c{Digit([0-9])}$").exec(part); + const numpadRegexpMatch = new RegExp("^c{Numpad([0-9])}$").exec(part); + const lowercaseCharMatch = new RegExp("^([a-z])$").exec(part); + if (part == "ArrowUp") { + return "Up"; + } + if (part == "ArrowDown") { + return "Down"; + } + if (part == "ArrowLeft") { + return "Left"; + } + if (part == "ArrowRight") { + return "Right"; + } + if (part == "Soft1") { + return "F21"; + } + if (part == "Soft2") { + return "F22"; + } + if (part == "Soft3") { + return "F23"; + } + if (part == "Soft4") { + return "F24"; + } + if (part == " ") { + return "Space"; + } + if (part == "CapsLock") { + return "Capslock"; + } + if (part == "NumLock") { + return "Numlock"; + } + if (part == "ScrollLock") { + return "Scrolllock"; + } + if (part == "AudioVolumeUp") { + return "VolumeUp"; + } + if (part == "AudioVolumeDown") { + return "VolumeDown"; + } + if (part == "AudioVolumeMute") { + return "VolumeMute"; + } + if (part == "MediaTrackNext") { + return "MediaNextTrack"; + } + if (part == "MediaTrackPrevious") { + return "MediaPreviousTrack"; + } + if (part == "Decimal") { + return "numdec"; + } + if (part == "Add") { + return "numadd"; + } + if (part == "Subtract") { + return "numsub"; + } + if (part == "Multiply") { + return "nummult"; + } + if (part == "Divide") { + return "numdiv"; + } + if (digitRegexpMatch && digitRegexpMatch.length > 1) { + return digitRegexpMatch[1]; + } + if (numpadRegexpMatch && numpadRegexpMatch.length > 1) { + return `num${numpadRegexpMatch[1]}`; + } + if (lowercaseCharMatch && lowercaseCharMatch.length > 1) { + return lowercaseCharMatch[1].toUpperCase(); + } + + return part; + }); + return electronParts.join("+"); +} diff --git a/emain/emain-wavesrv.ts b/emain/emain-wavesrv.ts new file mode 100644 index 000000000..f58d214a7 --- /dev/null +++ b/emain/emain-wavesrv.ts @@ -0,0 +1,139 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as electron from "electron"; +import * as child_process from "node:child_process"; +import * as readline from "readline"; +import { WebServerEndpointVarName, WSServerEndpointVarName } from "../frontend/util/endpoints"; +import { AuthKey, WaveAuthKeyEnv } from "./authkey"; +import { setForceQuit, setUserConfirmedQuit } from "./emain-activity"; +import { + getElectronAppResourcesPath, + getElectronAppUnpackedBasePath, + getWaveConfigDir, + getWaveDataDir, + getWaveSrvCwd, + getWaveSrvPath, + getXdgCurrentDesktop, + WaveConfigHomeVarName, + WaveDataHomeVarName, +} from "./emain-platform"; +import { + getElectronExecPath, + WaveAppElectronExecPath, + WaveAppPathVarName, + WaveAppResourcesPathVarName, +} from "./emain-util"; +import { updater } from "./updater"; + +let isWaveSrvDead = false; +let waveSrvProc: child_process.ChildProcessWithoutNullStreams | null = null; +let WaveVersion = "unknown"; // set by WAVESRV-ESTART +let WaveBuildTime = 0; // set by WAVESRV-ESTART + +export function getWaveVersion(): { version: string; buildTime: number } { + return { version: WaveVersion, buildTime: WaveBuildTime }; +} + +let waveSrvReadyResolve = (value: boolean) => {}; +const waveSrvReady: Promise = new Promise((resolve, _) => { + waveSrvReadyResolve = resolve; +}); + +export function getWaveSrvReady(): Promise { + return waveSrvReady; +} + +export function getWaveSrvProc(): child_process.ChildProcessWithoutNullStreams | null { + return waveSrvProc; +} + +export function getIsWaveSrvDead(): boolean { + return isWaveSrvDead; +} + +export function runWaveSrv(handleWSEvent: (evtMsg: WSEventType) => void): Promise { + let pResolve: (value: boolean) => void; + let pReject: (reason?: any) => void; + const rtnPromise = new Promise((argResolve, argReject) => { + pResolve = argResolve; + pReject = argReject; + }); + const envCopy = { ...process.env }; + const xdgCurrentDesktop = getXdgCurrentDesktop(); + if (xdgCurrentDesktop != null) { + envCopy["XDG_CURRENT_DESKTOP"] = xdgCurrentDesktop; + } + envCopy[WaveAppPathVarName] = getElectronAppUnpackedBasePath(); + envCopy[WaveAppResourcesPathVarName] = getElectronAppResourcesPath(); + envCopy[WaveAppElectronExecPath] = getElectronExecPath(); + envCopy[WaveAuthKeyEnv] = AuthKey; + envCopy[WaveDataHomeVarName] = getWaveDataDir(); + envCopy[WaveConfigHomeVarName] = getWaveConfigDir(); + const waveSrvCmd = getWaveSrvPath(); + console.log("trying to run local server", waveSrvCmd); + const proc = child_process.spawn(getWaveSrvPath(), { + cwd: getWaveSrvCwd(), + env: envCopy, + }); + proc.on("exit", (e) => { + if (updater?.status == "installing") { + return; + } + console.log("wavesrv exited, shutting down"); + setForceQuit(true); + isWaveSrvDead = true; + electron.app.quit(); + }); + proc.on("spawn", (e) => { + console.log("spawned wavesrv"); + waveSrvProc = proc; + pResolve(true); + }); + proc.on("error", (e) => { + console.log("error running wavesrv", e); + pReject(e); + }); + const rlStdout = readline.createInterface({ + input: proc.stdout, + terminal: false, + }); + rlStdout.on("line", (line) => { + console.log(line); + }); + const rlStderr = readline.createInterface({ + input: proc.stderr, + terminal: false, + }); + rlStderr.on("line", (line) => { + if (line.includes("WAVESRV-ESTART")) { + const startParams = /ws:([a-z0-9.:]+) web:([a-z0-9.:]+) version:([a-z0-9.-]+) buildtime:(\d+)/gm.exec( + line + ); + if (startParams == null) { + console.log("error parsing WAVESRV-ESTART line", line); + setUserConfirmedQuit(true); + electron.app.quit(); + return; + } + process.env[WSServerEndpointVarName] = startParams[1]; + process.env[WebServerEndpointVarName] = startParams[2]; + WaveVersion = startParams[3]; + WaveBuildTime = parseInt(startParams[4]); + waveSrvReadyResolve(true); + return; + } + if (line.startsWith("WAVESRV-EVENT:")) { + const evtJson = line.slice("WAVESRV-EVENT:".length); + try { + const evtMsg: WSEventType = JSON.parse(evtJson); + handleWSEvent(evtMsg); + } catch (e) { + console.log("error handling WAVESRV-EVENT", e); + } + return; + } + console.log(line); + }); + return rtnPromise; +} diff --git a/emain/emain-web.ts b/emain/emain-web.ts new file mode 100644 index 000000000..0269a6e8f --- /dev/null +++ b/emain/emain-web.ts @@ -0,0 +1,96 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { ipcMain, webContents, WebContents } from "electron"; +import { WaveBrowserWindow } from "./emain-window"; + +export function getWebContentsByBlockId(ww: WaveBrowserWindow, tabId: string, blockId: string): Promise { + const prtn = new Promise((resolve, reject) => { + const randId = Math.floor(Math.random() * 1000000000).toString(); + const respCh = `getWebContentsByBlockId-${randId}`; + ww?.activeTabView?.webContents.send("webcontentsid-from-blockid", blockId, respCh); + ipcMain.once(respCh, (event, webContentsId) => { + if (webContentsId == null) { + resolve(null); + return; + } + const wc = webContents.fromId(parseInt(webContentsId)); + resolve(wc); + }); + setTimeout(() => { + reject(new Error("timeout waiting for response")); + }, 2000); + }); + return prtn; +} + +function escapeSelector(selector: string): string { + return selector + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/'/g, "\\'") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/\t/g, "\\t"); +} + +export type WebGetOpts = { + all?: boolean; + inner?: boolean; +}; + +export async function webClickElement(wc: WebContents, selector: string): Promise { + if (!wc || !selector) { + throw new Error("webContents and selector are required"); + } + const escapedSelector = escapeSelector(selector); + const execExpr = ` + (() => { + try { + const el = document.querySelector("${escapedSelector}"); + if (!el) return { error: "element not found: ${escapedSelector}" }; + el.click(); + return { value: true }; + } catch (error) { + return { error: error.message }; + } + })()`; + const results = await wc.executeJavaScript(execExpr); + if (results.error) { + throw new Error(results.error); + } + return results.value; +} + +export async function webScreenshot(wc: WebContents): Promise { + if (!wc) { + throw new Error("webContents is required"); + } + const image = await wc.capturePage(); + return image.toPNG().toString("base64"); +} + +export async function webGetSelector(wc: WebContents, selector: string, opts?: WebGetOpts): Promise { + if (!wc || !selector) { + return null; + } + const escapedSelector = escapeSelector(selector); + const queryMethod = opts?.all ? "querySelectorAll" : "querySelector"; + const prop = opts?.inner ? "innerHTML" : "outerHTML"; + const execExpr = ` + (() => { + const toArr = x => (x instanceof NodeList) ? Array.from(x) : (x ? [x] : []); + try { + const result = document.${queryMethod}("${escapedSelector}"); + const value = toArr(result).map(el => el.${prop}); + return { value }; + } catch (error) { + return { error: error.message }; + } + })()`; + const results = await wc.executeJavaScript(execExpr); + if (results.error) { + throw new Error(results.error); + } + return results.value; +} diff --git a/emain/emain-window.ts b/emain/emain-window.ts new file mode 100644 index 000000000..b65d9f94d --- /dev/null +++ b/emain/emain-window.ts @@ -0,0 +1,1134 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { ClientService, ObjectService, WindowService, WorkspaceService } from "@/app/store/services"; +import { waveEventSubscribeSingle } from "@/app/store/wps"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { fireAndForget } from "@/util/util"; +import { BaseWindow, BaseWindowConstructorOptions, dialog, globalShortcut, ipcMain, screen, webContents } from "electron"; +import { globalEvents } from "emain/emain-events"; +import path from "path"; +import { debounce } from "throttle-debounce"; +import { + getGlobalIsQuitting, + getGlobalIsRelaunching, + setGlobalIsRelaunching, + setWasActive, + setWasInFg, +} from "./emain-activity"; +import { log } from "./emain-log"; +import { getElectronAppBasePath, isDev, unamePlatform } from "./emain-platform"; +import { getOrCreateWebViewForTab, getWaveTabViewByWebContentsId, WaveTabView } from "./emain-tabview"; +import { delay, ensureBoundsAreVisible, waveKeyToElectronKey } from "./emain-util"; +import { ElectronWshClient } from "./emain-wsh"; +import { updater } from "./updater"; + +const DevInitTimeoutMs = 5000; + +export type WindowOpts = { + unamePlatform: NodeJS.Platform; + isPrimaryStartupWindow?: boolean; + foregroundWindow?: boolean; +}; + +export const MinWindowWidth = 800; +export const MinWindowHeight = 500; + +export function calculateWindowBounds( + winSize?: { width?: number; height?: number }, + pos?: { x?: number; y?: number }, + settings?: any +): { x: number; y: number; width: number; height: number } { + let winWidth = winSize?.width; + let winHeight = winSize?.height; + const winPosX = pos?.x ?? 100; + const winPosY = pos?.y ?? 100; + + if ( + (winWidth == null || winWidth === 0 || winHeight == null || winHeight === 0) && + settings?.["window:dimensions"] + ) { + const dimensions = settings["window:dimensions"]; + const match = dimensions.match(/^(\d+)[xX](\d+)$/); + + if (match) { + const [, dimensionWidth, dimensionHeight] = match; + const parsedWidth = parseInt(dimensionWidth, 10); + const parsedHeight = parseInt(dimensionHeight, 10); + + if ((!winWidth || winWidth === 0) && Number.isFinite(parsedWidth) && parsedWidth > 0) { + winWidth = parsedWidth; + } + if ((!winHeight || winHeight === 0) && Number.isFinite(parsedHeight) && parsedHeight > 0) { + winHeight = parsedHeight; + } + } else { + console.warn('Invalid window:dimensions format. Expected "widthxheight".'); + } + } + + if (winWidth == null || winWidth == 0) { + const primaryDisplay = screen.getPrimaryDisplay(); + const { width } = primaryDisplay.workAreaSize; + winWidth = width - winPosX - 100; + if (winWidth > 2000) { + winWidth = 2000; + } + } + if (winHeight == null || winHeight == 0) { + const primaryDisplay = screen.getPrimaryDisplay(); + const { height } = primaryDisplay.workAreaSize; + winHeight = height - winPosY - 100; + if (winHeight > 1200) { + winHeight = 1200; + } + } + + winWidth = Math.max(winWidth, MinWindowWidth); + winHeight = Math.max(winHeight, MinWindowHeight); + + const winBounds = { + x: winPosX, + y: winPosY, + width: winWidth, + height: winHeight, + }; + return ensureBoundsAreVisible(winBounds); +} + +export const waveWindowMap = new Map(); // waveWindowId -> WaveBrowserWindow + +// on blur we do not set this to null (but on destroy we do), so this tracks the *last* focused window +// e.g. it persists when the app itself is not focused +export let focusedWaveWindow: WaveBrowserWindow = null; + +// quake window for toggle hotkey (show/hide behavior) +let quakeWindow: WaveBrowserWindow | null = null; + +export function getQuakeWindow(): WaveBrowserWindow | null { + return quakeWindow; +} + +let cachedClientId: string = null; +let hasCompletedFirstRelaunch = false; + +async function getClientId() { + if (cachedClientId != null) { + return cachedClientId; + } + const clientData = await ClientService.GetClientData(); + cachedClientId = clientData?.oid; + return cachedClientId; +} + +type WindowActionQueueEntry = + | { + op: "switchtab"; + tabId: string; + setInBackend: boolean; + primaryStartupTab?: boolean; + } + | { + op: "createtab"; + } + | { + op: "closetab"; + tabId: string; + } + | { + op: "switchworkspace"; + workspaceId: string; + }; + +function isNonEmptyUnsavedWorkspace(workspace: Workspace): boolean { + return !workspace.name && !workspace.icon && workspace.tabids?.length > 1; +} + +export class WaveBrowserWindow extends BaseWindow { + waveWindowId: string; + workspaceId: string; + allLoadedTabViews: Map; + activeTabView: WaveTabView; + private canClose: boolean; + private deleteAllowed: boolean; + private actionQueue: WindowActionQueueEntry[]; + + constructor(waveWindow: WaveWindow, fullConfig: FullConfigType, opts: WindowOpts) { + const settings = fullConfig?.settings; + + console.log("create win", waveWindow.oid); + const winBounds = calculateWindowBounds(waveWindow.winsize, waveWindow.pos, settings); + const winOpts: BaseWindowConstructorOptions = { + x: winBounds.x, + y: winBounds.y, + width: winBounds.width, + height: winBounds.height, + minWidth: MinWindowWidth, + minHeight: MinWindowHeight, + show: false, + }; + + const isTransparent = settings?.["window:transparent"] ?? false; + const isBlur = !isTransparent && (settings?.["window:blur"] ?? false); + + if (opts.unamePlatform === "darwin") { + winOpts.titleBarStyle = "hiddenInset"; + winOpts.titleBarOverlay = false; + winOpts.autoHideMenuBar = !settings?.["window:showmenubar"]; + winOpts.acceptFirstMouse = true; + if (isTransparent) { + winOpts.transparent = true; + } else if (isBlur) { + winOpts.vibrancy = "fullscreen-ui"; + } else { + winOpts.backgroundColor = "#222222"; + } + } else if (opts.unamePlatform === "linux") { + winOpts.titleBarStyle = settings["window:nativetitlebar"] ? "default" : "hidden"; + winOpts.titleBarOverlay = { + symbolColor: "white", + color: "#00000000", + }; + winOpts.icon = path.join(getElectronAppBasePath(), "public/logos/wave-logo-dark.png"); + winOpts.autoHideMenuBar = !settings?.["window:showmenubar"]; + if (isTransparent) { + winOpts.transparent = true; + } else { + winOpts.backgroundColor = "#222222"; + } + } else if (opts.unamePlatform === "win32") { + winOpts.titleBarStyle = "hidden"; + winOpts.titleBarOverlay = { + color: "#222222", + symbolColor: "#c3c8c2", + height: 32, + }; + if (isTransparent) { + winOpts.transparent = true; + } else if (isBlur) { + winOpts.backgroundMaterial = "acrylic"; + } else { + winOpts.backgroundColor = "#222222"; + } + } + + super(winOpts); + + if (opts.unamePlatform === "win32") { + this.setMenu(null); + } + + const fullscreenOnLaunch = fullConfig?.settings["window:fullscreenonlaunch"]; + if (fullscreenOnLaunch && opts.foregroundWindow) { + this.once("show", () => { + this.setFullScreen(true); + }); + } + this.actionQueue = []; + this.waveWindowId = waveWindow.oid; + this.workspaceId = waveWindow.workspaceid; + this.allLoadedTabViews = new Map(); + const winBoundsPoller = setInterval(() => { + if (this.isDestroyed()) { + clearInterval(winBoundsPoller); + return; + } + if (this.actionQueue.length > 0) { + return; + } + this.finalizePositioning(); + }, 1000); + this.on( + // @ts-expect-error -- "resize" event with debounce handler not in Electron type definitions + "resize", + debounce(400, (e) => this.mainResizeHandler(e)) + ); + this.on("resize", () => { + if (this.isDestroyed()) { + return; + } + this.activeTabView?.positionTabOnScreen(this.getContentBounds()); + }); + this.on( + // @ts-expect-error -- "move" event with debounce handler not in Electron type definitions + "move", + debounce(400, (e) => this.mainResizeHandler(e)) + ); + this.on("enter-full-screen", async () => { + if (this.isDestroyed()) { + return; + } + console.log("enter-full-screen event", this.getContentBounds()); + this.broadcastFullScreenState(true); + this.activeTabView?.positionTabOnScreen(this.getContentBounds()); + }); + this.on("leave-full-screen", async () => { + if (this.isDestroyed()) { + return; + } + this.broadcastFullScreenState(false); + this.activeTabView?.positionTabOnScreen(this.getContentBounds()); + }); + this.on("focus", () => { + if (this.isDestroyed()) { + return; + } + if (getGlobalIsRelaunching()) { + return; + } + focusedWaveWindow = this; // eslint-disable-line @typescript-eslint/no-this-alias + console.log("focus win", this.waveWindowId); + fireAndForget(() => ClientService.FocusWindow(this.waveWindowId)); + setWasInFg(true); + setWasActive(true); + setTimeout(() => globalEvents.emit("windows-updated"), 50); + }); + this.on("blur", () => { + setTimeout(() => globalEvents.emit("windows-updated"), 50); + }); + this.on("close", (e) => { + if (this.canClose) { + return; + } + if (this.isDestroyed()) { + return; + } + this.closeAllDevTools(); + console.log("win 'close' handler fired", this.waveWindowId); + if (getGlobalIsQuitting() || updater?.status == "installing" || getGlobalIsRelaunching()) { + return; + } + e.preventDefault(); + fireAndForget(async () => { + const numWindows = waveWindowMap.size; + const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); + if (numWindows > 1 || !fullConfig.settings["window:savelastwindow"]) { + if (fullConfig.settings["window:confirmclose"]) { + const workspace = await WorkspaceService.GetWorkspace(this.workspaceId); + if (isNonEmptyUnsavedWorkspace(workspace)) { + const choice = dialog.showMessageBoxSync(this, { + type: "question", + buttons: ["Cancel", "Close Window"], + title: "Confirm", + message: + "Window has unsaved tabs, closing window will delete existing tabs.\n\nContinue?", + }); + if (choice === 0) { + return; + } + } + } + this.deleteAllowed = true; + } + this.canClose = true; + this.close(); + }); + }); + this.on("closed", () => { + console.log("win 'closed' handler fired", this.waveWindowId); + if (getGlobalIsQuitting() || updater?.status == "installing") { + console.log("win quitting or updating", this.waveWindowId); + return; + } + setTimeout(() => globalEvents.emit("windows-updated"), 50); + waveWindowMap.delete(this.waveWindowId); + if (focusedWaveWindow == this) { + focusedWaveWindow = null; + } + if (quakeWindow == this) { + quakeWindow = null; + } + this.removeAllChildViews(); + if (getGlobalIsRelaunching()) { + console.log("win relaunching", this.waveWindowId); + this.destroy(); + return; + } + if (this.deleteAllowed) { + console.log("win removing window from backend DB", this.waveWindowId); + fireAndForget(() => WindowService.CloseWindow(this.waveWindowId, true)); + } + }); + waveWindowMap.set(waveWindow.oid, this); + setTimeout(() => globalEvents.emit("windows-updated"), 50); + } + + private closeAllDevTools() { + for (const tabView of this.allLoadedTabViews.values()) { + if (tabView.webContents?.isDevToolsOpened()) { + tabView.webContents.closeDevTools(); + } + } + const tabViewIds = new Set( + [...this.allLoadedTabViews.values()].map((tv) => tv.webContents?.id).filter((id) => id != null) + ); + for (const wc of webContents.getAllWebContents()) { + if (wc.getType() === "webview" && tabViewIds.has(wc.hostWebContents?.id)) { + if (wc.isDevToolsOpened()) { + wc.closeDevTools(); + } + } + } + } + + private removeAllChildViews() { + for (const tabView of this.allLoadedTabViews.values()) { + if (!this.isDestroyed()) { + this.contentView.removeChildView(tabView); + } + tabView?.destroy(); + } + } + + async switchWorkspace(workspaceId: string) { + console.log("switchWorkspace", workspaceId, this.waveWindowId); + if (workspaceId == this.workspaceId) { + console.log("switchWorkspace already on this workspace", this.waveWindowId); + return; + } + + // If the workspace is already owned by a window, then we can just call SwitchWorkspace without first prompting the user, since it'll just focus to the other window. + const workspaceList = await WorkspaceService.ListWorkspaces(); + if (!workspaceList?.find((wse) => wse.workspaceid === workspaceId)?.windowid) { + const curWorkspace = await WorkspaceService.GetWorkspace(this.workspaceId); + + if (curWorkspace && isNonEmptyUnsavedWorkspace(curWorkspace)) { + console.log( + `existing unsaved workspace ${this.workspaceId} has content, opening workspace ${workspaceId} in new window` + ); + await createWindowForWorkspace(workspaceId); + return; + } + } + await this._queueActionInternal({ op: "switchworkspace", workspaceId }); + } + + async setActiveTab(tabId: string, setInBackend: boolean, primaryStartupTab = false) { + console.log( + "setActiveTab", + tabId, + this.waveWindowId, + this.workspaceId, + setInBackend, + primaryStartupTab ? "(primary startup)" : "" + ); + await this._queueActionInternal({ op: "switchtab", tabId, setInBackend, primaryStartupTab }); + } + + private async initializeTab(tabView: WaveTabView, primaryStartupTab: boolean) { + const clientId = await getClientId(); + const initOpts: WaveInitOpts = { + tabId: tabView.waveTabId, + clientId: clientId, + windowId: this.waveWindowId, + activate: true, + }; + if (primaryStartupTab) { + initOpts.primaryTabStartup = true; + } + // Set savedInitOpts BEFORE awaiting initPromise so that if the + // renderer's "ready" IPC arrives after the dev-mode 5s timeout, + // the IPC handler in emain-ipc.ts can still fire `wave-init` + // (it gates on tabView.savedInitOpts != null). Previously the + // assignment was after the await — if the await threw on + // timeout, savedInitOpts stayed null forever and the renderer + // got stuck in initBare with no wave-init ever delivered. Easy + // to trip when vite is re-optimizing dependencies (cold start, + // post-`npm install`, large HMR edits). + tabView.savedInitOpts = { ...initOpts }; + tabView.savedInitOpts.activate = false; + delete tabView.savedInitOpts.primaryTabStartup; + await this.awaitWithDevTimeout(tabView.initPromise, "initPromise", tabView.waveTabId); + const winBounds = this.getContentBounds(); + tabView.setBounds({ x: 0, y: 0, width: winBounds.width, height: winBounds.height }); + this.contentView.addChildView(tabView); + const startTime = Date.now(); + console.log( + "before wave ready, init tab, sending wave-init", + tabView.waveTabId, + primaryStartupTab ? "(primary startup)" : "" + ); + tabView.webContents.send("wave-init", initOpts); + await this.awaitWithDevTimeout(tabView.waveReadyPromise, "waveReadyPromise", tabView.waveTabId); + console.log("wave-ready init time", Date.now() - startTime + "ms"); + } + + private async awaitWithDevTimeout(promise: Promise, name: string, tabId: string): Promise { + if (!isDev) { + return promise; + } + let timeoutHandle: ReturnType = null; + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + console.log( + `[dev] ${name} timed out after ${DevInitTimeoutMs}ms for tab ${tabId}, showing window for devtools` + ); + if (!this.isDestroyed() && !this.isVisible()) { + this.show(); + } + if (this.activeTabView?.webContents && !this.activeTabView.webContents.isDevToolsOpened()) { + this.activeTabView.webContents.openDevTools(); + } + reject(new Error(`[dev] ${name} timed out after ${DevInitTimeoutMs}ms`)); + }, DevInitTimeoutMs); + }); + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + clearTimeout(timeoutHandle); + } + } + + private async setTabViewIntoWindow(tabView: WaveTabView, tabInitialized: boolean, primaryStartupTab = false) { + if (this.activeTabView == tabView) { + return; + } + const oldActiveView = this.activeTabView; + tabView.isActiveTab = true; + if (oldActiveView != null) { + oldActiveView.isActiveTab = false; + } + this.activeTabView = tabView; + this.allLoadedTabViews.set(tabView.waveTabId, tabView); + if (!tabInitialized) { + console.log("initializing a new tab", primaryStartupTab ? "(primary startup)" : ""); + await this.initializeTab(tabView, primaryStartupTab); + this.finalizePositioning(); + } else { + console.log("reusing an existing tab, calling wave-init", tabView.waveTabId); + tabView.webContents.send("wave-init", tabView.savedInitOpts); // reinit + this.finalizePositioning(); + } + + // something is causing the new tab to lose focus so it requires manual refocusing + tabView.webContents.focus(); + setTimeout(() => { + if (tabView.webContents && this.activeTabView == tabView && !tabView.webContents.isFocused()) { + tabView.webContents.focus(); + } + }, 10); + setTimeout(() => { + if (tabView.webContents && this.activeTabView == tabView && !tabView.webContents.isFocused()) { + tabView.webContents.focus(); + } + }, 30); + } + + private finalizePositioning() { + if (this.isDestroyed()) { + return; + } + const curBounds = this.getContentBounds(); + this.activeTabView?.positionTabOnScreen(curBounds); + for (const tabView of this.allLoadedTabViews.values()) { + if (tabView == this.activeTabView) { + continue; + } + tabView?.positionTabOffScreen(curBounds); + } + } + + // Broadcasts fullscreen state to all loaded tab views. Each tab's renderer + // has its own atom store; enter/leave-full-screen only reaches the active + // tab otherwise, leaving other tabs stale. + broadcastFullScreenState(isFullScreen: boolean) { + for (const tabView of this.allLoadedTabViews.values()) { + if (tabView?.webContents && !tabView.webContents.isDestroyed()) { + tabView.webContents.send("fullscreen-change", isFullScreen); + } + } + } + + async queueCreateTab() { + await this._queueActionInternal({ op: "createtab" }); + } + + async queueCloseTab(tabId: string) { + await this._queueActionInternal({ op: "closetab", tabId }); + } + + private async _queueActionInternal(entry: WindowActionQueueEntry) { + if (this.actionQueue.length >= 2) { + this.actionQueue[1] = entry; + return; + } + const wasEmpty = this.actionQueue.length === 0; + this.actionQueue.push(entry); + if (wasEmpty) { + await this.processActionQueue(); + } + } + + private removeTabViewLater(tabId: string, delayMs: number) { + setTimeout(() => { + this.removeTabView(tabId, false); + }, delayMs); + } + + // the queue and this function are used to serialize operations that update the window contents view + // processActionQueue will replace [1] if it is already set + // we don't mess with [0] because it is "in process" + // we replace [1] because there is no point to run an action that is going to be overwritten + private async processActionQueue() { + while (this.actionQueue.length > 0) { + try { + if (this.isDestroyed()) { + break; + } + const entry = this.actionQueue[0]; + let tabId: string = null; + // have to use "===" here to get the typechecker to work :/ + switch (entry.op) { + case "createtab": + tabId = await WorkspaceService.CreateTab(this.workspaceId, null, true); + break; + case "switchtab": + tabId = entry.tabId; + if (this.activeTabView?.waveTabId == tabId) { + continue; + } + if (entry.setInBackend) { + await WorkspaceService.SetActiveTab(this.workspaceId, tabId); + } + break; + case "closetab": { + tabId = entry.tabId; + const rtn = await WorkspaceService.CloseTab(this.workspaceId, tabId, true); + if (rtn == null) { + console.log( + "[error] closeTab: no return value", + tabId, + this.workspaceId, + this.waveWindowId + ); + return; + } + this.removeTabViewLater(tabId, 1000); + if (rtn.closewindow) { + this.close(); + return; + } + if (!rtn.newactivetabid) { + return; + } + tabId = rtn.newactivetabid; + break; + } + case "switchworkspace": { + const newWs = await WindowService.SwitchWorkspace(this.waveWindowId, entry.workspaceId); + if (!newWs) { + return; + } + console.log("processActionQueue switchworkspace newWs", newWs); + this.removeAllChildViews(); + console.log("destroyed all tabs", this.waveWindowId); + this.workspaceId = entry.workspaceId; + this.allLoadedTabViews = new Map(); + tabId = newWs.activetabid; + break; + } + } + if (tabId == null) { + return; + } + const [tabView, tabInitialized] = await getOrCreateWebViewForTab(this.waveWindowId, tabId); + const primaryStartupTabFlag = entry.op === "switchtab" ? (entry.primaryStartupTab ?? false) : false; + await this.setTabViewIntoWindow(tabView, tabInitialized, primaryStartupTabFlag); + } catch (e) { + console.log("error caught in processActionQueue", e); + } finally { + this.actionQueue.shift(); + } + } + } + + private async mainResizeHandler(_: any) { + if (this == null || this.isDestroyed() || this.fullScreen) { + return; + } + const bounds = this.getBounds(); + try { + await WindowService.SetWindowPosAndSize( + this.waveWindowId, + { x: bounds.x, y: bounds.y }, + { width: bounds.width, height: bounds.height } + ); + } catch (e) { + console.log("error sending new window bounds to backend", e); + } + } + + removeTabView(tabId: string, force: boolean) { + if (!force && this.activeTabView?.waveTabId == tabId) { + console.log("cannot remove active tab", tabId, this.waveWindowId); + return; + } + const tabView = this.allLoadedTabViews.get(tabId); + if (tabView == null) { + console.log("removeTabView -- tabView not found", tabId, this.waveWindowId); + // the tab was never loaded, so just return + return; + } + this.contentView.removeChildView(tabView); + this.allLoadedTabViews.delete(tabId); + tabView.destroy(); + } + + destroy() { + console.log("destroy win", this.waveWindowId); + this.deleteAllowed = true; + super.destroy(); + } +} + +export function getWaveWindowByTabId(tabId: string): WaveBrowserWindow { + for (const ww of waveWindowMap.values()) { + if (ww.allLoadedTabViews.has(tabId)) { + return ww; + } + } +} + +export function getWaveWindowByWebContentsId(webContentsId: number): WaveBrowserWindow { + if (webContentsId == null) { + return null; + } + const tabView = getWaveTabViewByWebContentsId(webContentsId); + if (tabView == null) { + return null; + } + return getWaveWindowByTabId(tabView.waveTabId); +} + +export function getWaveWindowById(windowId: string): WaveBrowserWindow { + return waveWindowMap.get(windowId); +} + +export function getWaveWindowByWorkspaceId(workspaceId: string): WaveBrowserWindow { + for (const waveWindow of waveWindowMap.values()) { + if (waveWindow.workspaceId === workspaceId) { + return waveWindow; + } + } +} + +export function getAllWaveWindows(): WaveBrowserWindow[] { + return Array.from(waveWindowMap.values()); +} + +export async function createWindowForWorkspace(workspaceId: string) { + const newWin = await WindowService.CreateWindow(null, workspaceId); + if (!newWin) { + console.log("error creating new window", this.waveWindowId); + } + const newBwin = await createBrowserWindow(newWin, await RpcApi.GetFullConfigCommand(ElectronWshClient), { + unamePlatform, + isPrimaryStartupWindow: false, + }); + newBwin.show(); +} + +// note, this does not *show* the window. +// to show, await win.readyPromise and then win.show() +export async function createBrowserWindow( + waveWindow: WaveWindow, + fullConfig: FullConfigType, + opts: WindowOpts +): Promise { + if (!waveWindow) { + console.log("createBrowserWindow: no waveWindow"); + waveWindow = await WindowService.CreateWindow(null, ""); + } + let workspace = await WorkspaceService.GetWorkspace(waveWindow.workspaceid); + if (!workspace) { + console.log("createBrowserWindow: no workspace, creating new window"); + await WindowService.CloseWindow(waveWindow.oid, true); + waveWindow = await WindowService.CreateWindow(null, ""); + workspace = await WorkspaceService.GetWorkspace(waveWindow.workspaceid); + } + console.log("createBrowserWindow", waveWindow.oid, workspace.oid, workspace); + const bwin = new WaveBrowserWindow(waveWindow, fullConfig, opts); + + if (workspace.activetabid) { + await bwin.setActiveTab(workspace.activetabid, false, opts.isPrimaryStartupWindow ?? false); + } + return bwin; +} + +ipcMain.on("set-active-tab", async (event, tabId) => { + const ww = getWaveWindowByWebContentsId(event.sender.id); + console.log("set-active-tab", tabId, ww?.waveWindowId); + await ww?.setActiveTab(tabId, true); +}); + +ipcMain.on("create-tab", async (event, _opts) => { + const senderWc = event.sender; + const ww = getWaveWindowByWebContentsId(senderWc.id); + if (ww != null) { + await ww.queueCreateTab(); + } + event.returnValue = true; + return null; +}); + +ipcMain.on("set-waveai-open", (event, isOpen: boolean) => { + const tabView = getWaveTabViewByWebContentsId(event.sender.id); + if (tabView) { + tabView.isWaveAIOpen = isOpen; + } +}); + +ipcMain.handle("close-tab", async (event, workspaceId: string, tabId: string, confirmClose: boolean) => { + const ww = getWaveWindowByWorkspaceId(workspaceId); + if (ww == null) { + console.log(`close-tab: no window found for workspace ws=${workspaceId} tab=${tabId}`); + return false; + } + if (confirmClose) { + const choice = dialog.showMessageBoxSync(ww, { + type: "question", + defaultId: 1, // Enter activates "Close Tab" + cancelId: 0, // Esc activates "Cancel" + buttons: ["Cancel", "Close Tab"], + title: "Confirm", + message: "Are you sure you want to close this tab?", + }); + if (choice === 0) { + return false; + } + } + await ww.queueCloseTab(tabId); + return true; +}); + +ipcMain.on("switch-workspace", (event, workspaceId) => { + fireAndForget(async () => { + const ww = getWaveWindowByWebContentsId(event.sender.id); + console.log("switch-workspace", workspaceId, ww?.waveWindowId); + await ww?.switchWorkspace(workspaceId); + }); +}); + +export async function createWorkspace(window: WaveBrowserWindow) { + const newWsId = await WorkspaceService.CreateWorkspace("", "", "", true); + if (newWsId) { + if (window) { + await window.switchWorkspace(newWsId); + } else { + await createWindowForWorkspace(newWsId); + } + } +} + +ipcMain.on("create-workspace", (event) => { + fireAndForget(async () => { + const ww = getWaveWindowByWebContentsId(event.sender.id); + console.log("create-workspace", ww?.waveWindowId); + await createWorkspace(ww); + }); +}); + +ipcMain.on("delete-workspace", (event, workspaceId) => { + fireAndForget(async () => { + const ww = getWaveWindowByWebContentsId(event.sender.id); + console.log("delete-workspace", workspaceId, ww?.waveWindowId); + + const workspaceList = await WorkspaceService.ListWorkspaces(); + + const _workspaceHasWindow = !!workspaceList.find((wse) => wse.workspaceid === workspaceId)?.windowid; + + const dialogOpts = { + type: "question" as const, + buttons: ["Cancel", "Delete Workspace"], + title: "Confirm", + message: `Deleting workspace will also delete its contents.\n\nContinue?`, + }; + const choice = ww ? dialog.showMessageBoxSync(ww, dialogOpts) : dialog.showMessageBoxSync(dialogOpts); + if (choice === 0) { + console.log("user cancelled workspace delete", workspaceId, ww?.waveWindowId); + return; + } + + const newWorkspaceId = await WorkspaceService.DeleteWorkspace(workspaceId); + console.log("delete-workspace done", workspaceId, ww?.waveWindowId); + if (ww?.workspaceId == workspaceId) { + if (newWorkspaceId) { + await ww.switchWorkspace(newWorkspaceId); + } else { + console.log("delete-workspace closing window", workspaceId, ww?.waveWindowId); + ww.destroy(); + } + } + }); +}); + +export async function createNewWaveWindow() { + log("createNewWaveWindow"); + const clientData = await ClientService.GetClientData(); + const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); + let recreatedWindow = false; + const allWindows = getAllWaveWindows(); + if (allWindows.length === 0 && clientData?.windowids?.length >= 1) { + console.log("no windows, but clientData has windowids, recreating first window"); + // reopen the first window + const existingWindowId = clientData.windowids[0]; + const existingWindowData = (await ObjectService.GetObject("window:" + existingWindowId)) as WaveWindow; + if (existingWindowData != null) { + const win = await createBrowserWindow(existingWindowData, fullConfig, { + unamePlatform, + isPrimaryStartupWindow: false, + }); + if (quakeWindow == null) { + quakeWindow = win; + } + win.show(); + recreatedWindow = true; + } + } + if (recreatedWindow) { + console.log("recreated window, returning"); + return; + } + console.log("creating new window"); + const newBrowserWindow = await createBrowserWindow(null, fullConfig, { + unamePlatform, + isPrimaryStartupWindow: false, + }); + if (quakeWindow == null) { + quakeWindow = newBrowserWindow; + } + newBrowserWindow.show(); +} + +export async function relaunchBrowserWindows() { + console.log("relaunchBrowserWindows"); + setGlobalIsRelaunching(true); + const windows = getAllWaveWindows(); + if (windows.length > 0) { + for (const window of windows) { + console.log("relaunch -- closing window", window.waveWindowId); + window.close(); + } + await delay(1200); + } + setGlobalIsRelaunching(false); + + const clientData = await ClientService.GetClientData(); + const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); + const windowIds = clientData.windowids ?? []; + const wins: WaveBrowserWindow[] = []; + const isFirstRelaunch = !hasCompletedFirstRelaunch; + const primaryWindowId = windowIds.length > 0 ? windowIds[0] : null; + for (const windowId of windowIds.slice().reverse()) { + const windowData: WaveWindow = await WindowService.GetWindow(windowId); + if (windowData == null) { + console.log("relaunch -- window data not found, closing window", windowId); + await WindowService.CloseWindow(windowId, true); + continue; + } + const isPrimaryStartupWindow = isFirstRelaunch && windowId === primaryWindowId; + console.log( + "relaunch -- creating window", + windowId, + windowData, + isPrimaryStartupWindow ? "(primary startup)" : "" + ); + const win = await createBrowserWindow(windowData, fullConfig, { + unamePlatform, + isPrimaryStartupWindow, + foregroundWindow: windowId === primaryWindowId, + }); + wins.push(win); + if (windowId === primaryWindowId) { + quakeWindow = win; + console.log("designated quake window", win.waveWindowId); + } + } + hasCompletedFirstRelaunch = true; + for (const win of wins) { + console.log("show window", win.waveWindowId); + win.show(); + } +} + +function getDisplayForQuakeToggle() { + // We cannot reliably query the OS-wide active window in Electron. + // Cursor position is the best cross-platform proxy for the user's active display. + const cursorPoint = screen.getCursorScreenPoint(); + const displayAtCursor = screen + .getAllDisplays() + .find( + (display) => + cursorPoint.x >= display.bounds.x && + cursorPoint.x < display.bounds.x + display.bounds.width && + cursorPoint.y >= display.bounds.y && + cursorPoint.y < display.bounds.y + display.bounds.height + ); + return displayAtCursor ?? screen.getDisplayNearestPoint(cursorPoint); +} + +function moveWindowToDisplay(win: WaveBrowserWindow, targetDisplay: Electron.Display) { + if (!win || !targetDisplay || win.isDestroyed()) { + return; + } + const curBounds = win.getBounds(); + const sourceDisplay = screen.getDisplayMatching(curBounds); + if (sourceDisplay.id === targetDisplay.id) { + return; + } + + const sourceArea = sourceDisplay.workArea; + const targetArea = targetDisplay.workArea; + const nextHeight = Math.min(curBounds.height, targetArea.height); + const nextWidth = Math.min(curBounds.width, targetArea.width); + const maxXOffset = Math.max(0, targetArea.width - nextWidth); + const maxYOffset = Math.max(0, targetArea.height - nextHeight); + const sourceXOffset = curBounds.x - sourceArea.x; + const sourceYOffset = curBounds.y - sourceArea.y; + const nextX = targetArea.x + Math.min(Math.max(sourceXOffset, 0), maxXOffset); + const nextY = targetArea.y + Math.min(Math.max(sourceYOffset, 0), maxYOffset); + + win.setBounds({ ...curBounds, x: nextX, y: nextY, width: nextWidth, height: nextHeight }); +} + +const FullscreenTransitionTimeoutMs = 2000; + +// handles a theoretical race condition where the user spams the hotkey before the toggle finishes +let quakeToggleInProgress = false; +let quakeRestoreFullscreenOnShow = false; + +function waitForFullscreenLeave(window: WaveBrowserWindow): Promise { + if (!window.isFullScreen()) { + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + // eslint-disable-next-line prefer-const + let timeout: ReturnType; + const onLeave = () => { + clearTimeout(timeout); + resolve(); + }; + timeout = setTimeout(() => { + window.removeListener("leave-full-screen", onLeave); + reject(new Error("fullscreen transition timeout")); + }, FullscreenTransitionTimeoutMs); + window.once("leave-full-screen", onLeave); + }); +} + +function waitForFullscreenEnter(window: WaveBrowserWindow): Promise { + if (window.isFullScreen()) { + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + // eslint-disable-next-line prefer-const + let timeout: ReturnType; + const onEnter = () => { + clearTimeout(timeout); + resolve(); + }; + timeout = setTimeout(() => { + window.removeListener("enter-full-screen", onEnter); + reject(new Error("fullscreen transition timeout")); + }, FullscreenTransitionTimeoutMs); + window.once("enter-full-screen", onEnter); + }); +} + +async function quakeToggle() { + if (quakeToggleInProgress) { + return; + } + quakeToggleInProgress = true; + try { + let window = quakeWindow; + if (window?.isDestroyed()) { + quakeWindow = null; + window = null; + } + if (window == null) { + await createNewWaveWindow(); + return; + } + // Some environments don't hide or move the window if it's fullscreen (even when hidden), so leave fullscreen first + if (window.isFullScreen()) { + // macos has a really long fullscreen animation and can have issues restoring from fullscreen, so we skip on macos + quakeRestoreFullscreenOnShow = process.platform !== "darwin"; + const leavePromise = waitForFullscreenLeave(window); + window.setFullScreen(false); + try { + await leavePromise; + } catch { + // timeout — proceed anyway + } + if (window.isDestroyed()) { + return; + } + } + if (window.isVisible()) { + window.hide(); + } else { + const targetDisplay = getDisplayForQuakeToggle(); + moveWindowToDisplay(window, targetDisplay); + window.show(); + if (quakeRestoreFullscreenOnShow) { + const enterPromise = waitForFullscreenEnter(window); + window.setFullScreen(true); + try { + await enterPromise; + } catch { + // timeout — proceed anyway + } + } + quakeRestoreFullscreenOnShow = false; + window.focus(); + if (window.activeTabView?.webContents) { + window.activeTabView.webContents.focus(); + } + } + } finally { + quakeToggleInProgress = false; + } +} + +let currentRawGlobalHotKey: string = null; +let currentGlobalHotKey: string = null; + +export function registerGlobalHotkey(rawGlobalHotKey: string) { + if (rawGlobalHotKey === currentRawGlobalHotKey) { + return; + } + if (currentGlobalHotKey != null) { + globalShortcut.unregister(currentGlobalHotKey); + currentGlobalHotKey = null; + currentRawGlobalHotKey = null; + } + if (!rawGlobalHotKey) { + return; + } + try { + const electronHotKey = waveKeyToElectronKey(rawGlobalHotKey); + const ok = globalShortcut.register(electronHotKey, () => { + fireAndForget(quakeToggle); + }); + currentRawGlobalHotKey = rawGlobalHotKey; + currentGlobalHotKey = electronHotKey; + console.log("registered globalhotkey", rawGlobalHotKey, "=>", electronHotKey, "ok=", ok); + } catch (e) { + console.log("error registering global hotkey", rawGlobalHotKey, ":", e); + } +} + +export function initGlobalHotkeyEventSubscription() { + waveEventSubscribeSingle({ + eventType: "config", + handler: (event) => { + try { + const hotkey = event?.data?.fullconfig?.settings?.["app:globalhotkey"]; + registerGlobalHotkey(hotkey ?? null); + } catch (e) { + console.log("error handling config event for globalhotkey", e); + } + }, + }); +} diff --git a/emain/emain-wsh.ts b/emain/emain-wsh.ts new file mode 100644 index 000000000..c9dba99a7 --- /dev/null +++ b/emain/emain-wsh.ts @@ -0,0 +1,158 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { WindowService } from "@/app/store/services"; +import { RpcResponseHelper, WshClient } from "@/app/store/wshclient"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { Notification, net, safeStorage, shell } from "electron"; +import { getResolvedUpdateChannel } from "emain/updater"; +import { unamePlatform } from "./emain-platform"; +import { getWebContentsByBlockId, webClickElement, webGetSelector, webScreenshot } from "./emain-web"; +import { createBrowserWindow, getWaveWindowById, getWaveWindowByWorkspaceId } from "./emain-window"; + +export class ElectronWshClientType extends WshClient { + constructor() { + super("electron"); + } + + async handle_webselector(rh: RpcResponseHelper, data: CommandWebSelectorData): Promise { + if (!data.tabid || !data.blockid || !data.workspaceid) { + throw new Error("tabid and blockid are required"); + } + const ww = getWaveWindowByWorkspaceId(data.workspaceid); + if (ww == null) { + throw new Error(`no window found with workspace ${data.workspaceid}`); + } + const wc = await getWebContentsByBlockId(ww, data.tabid, data.blockid); + if (wc == null) { + throw new Error(`no webcontents found with blockid ${data.blockid}`); + } + const rtn = await webGetSelector(wc, data.selector, data.opts); + return rtn; + } + + async handle_webclick(rh: RpcResponseHelper, data: CommandWebClickData): Promise { + if (!data.tabid || !data.blockid || !data.workspaceid || !data.selector) { + throw new Error("tabid, blockid, workspaceid, and selector are required"); + } + const ww = getWaveWindowByWorkspaceId(data.workspaceid); + if (ww == null) { + throw new Error(`no window found with workspace ${data.workspaceid}`); + } + const wc = await getWebContentsByBlockId(ww, data.tabid, data.blockid); + if (wc == null) { + throw new Error(`no webcontents found with blockid ${data.blockid}`); + } + return await webClickElement(wc, data.selector); + } + + async handle_webscreenshot(rh: RpcResponseHelper, data: CommandWebScreenshotData): Promise { + if (!data.tabid || !data.blockid || !data.workspaceid) { + throw new Error("tabid, blockid, and workspaceid are required"); + } + const ww = getWaveWindowByWorkspaceId(data.workspaceid); + if (ww == null) { + throw new Error(`no window found with workspace ${data.workspaceid}`); + } + const wc = await getWebContentsByBlockId(ww, data.tabid, data.blockid); + if (wc == null) { + throw new Error(`no webcontents found with blockid ${data.blockid}`); + } + return await webScreenshot(wc); + } + + async handle_notify(rh: RpcResponseHelper, notificationOptions: WaveNotificationOptions) { + new Notification({ + title: notificationOptions.title, + body: notificationOptions.body, + silent: notificationOptions.silent, + }).show(); + } + + async handle_getupdatechannel(rh: RpcResponseHelper): Promise { + return getResolvedUpdateChannel(); + } + + async handle_focuswindow(rh: RpcResponseHelper, windowId: string) { + console.log(`focuswindow ${windowId}`); + const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); + let ww = getWaveWindowById(windowId); + if (ww == null) { + const window = await WindowService.GetWindow(windowId); + if (window == null) { + throw new Error(`window ${windowId} not found`); + } + ww = await createBrowserWindow(window, fullConfig, { + unamePlatform, + isPrimaryStartupWindow: false, + }); + } + ww.focus(); + } + + async handle_electronencrypt( + rh: RpcResponseHelper, + data: CommandElectronEncryptData + ): Promise { + if (!safeStorage.isEncryptionAvailable()) { + throw new Error("encryption is not available"); + } + const encrypted = safeStorage.encryptString(data.plaintext); + const ciphertext = encrypted.toString("base64"); + + let storagebackend = ""; + if (process.platform === "linux") { + storagebackend = safeStorage.getSelectedStorageBackend(); + } + + return { + ciphertext, + storagebackend, + }; + } + + async handle_electrondecrypt( + rh: RpcResponseHelper, + data: CommandElectronDecryptData + ): Promise { + if (!safeStorage.isEncryptionAvailable()) { + throw new Error("encryption is not available"); + } + const encrypted = Buffer.from(data.ciphertext, "base64"); + const plaintext = safeStorage.decryptString(encrypted); + + let storagebackend = ""; + if (process.platform === "linux") { + storagebackend = safeStorage.getSelectedStorageBackend(); + } + + return { + plaintext, + storagebackend, + }; + } + + async handle_networkonline(rh: RpcResponseHelper): Promise { + return net.isOnline(); + } + + async handle_electronsystembell(rh: RpcResponseHelper): Promise { + shell.beep(); + } + + // async handle_workspaceupdate(rh: RpcResponseHelper) { + // console.log("workspaceupdate"); + // fireAndForget(async () => { + // console.log("workspace menu clicked"); + // const updatedWorkspaceMenu = await getWorkspaceMenu(); + // const workspaceMenu = Menu.getApplicationMenu().getMenuItemById("workspace-menu"); + // workspaceMenu.submenu = Menu.buildFromTemplate(updatedWorkspaceMenu); + // }); + // } +} + +export let ElectronWshClient: ElectronWshClientType; + +export function initElectronWshClient() { + ElectronWshClient = new ElectronWshClientType(); +} diff --git a/emain/emain.ts b/emain/emain.ts new file mode 100644 index 000000000..0ba63773e --- /dev/null +++ b/emain/emain.ts @@ -0,0 +1,467 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { RpcApi } from "@/app/store/wshclientapi"; +import * as electron from "electron"; +import { focusedBuilderWindow, getAllBuilderWindows } from "emain/emain-builder"; +import { globalEvents } from "emain/emain-events"; +import { sprintf } from "sprintf-js"; +import * as services from "../frontend/app/store/services"; +import { initElectronWshrpc, shutdownWshrpc } from "../frontend/app/store/wshrpcutil-base"; +import { fireAndForget, sleep } from "../frontend/util/util"; +import { AuthKey, configureAuthKeyRequestInjection } from "./authkey"; +import { + getActivityState, + getAndClearTermCommandsDurable, + getAndClearTermCommandsRemote, + getAndClearTermCommandsRun, + getAndClearTermCommandsWsl, + getForceQuit, + getGlobalIsRelaunching, + getUserConfirmedQuit, + setForceQuit, + setGlobalIsQuitting, + setGlobalIsStarting, + setUserConfirmedQuit, + setWasActive, + setWasInFg, +} from "./emain-activity"; +import { initIpcHandlers } from "./emain-ipc"; +import { log } from "./emain-log"; +import { initMenuEventSubscriptions, makeAndSetAppMenu, makeDockTaskbar } from "./emain-menu"; +import { + checkIfRunningUnderARM64Translation, + getElectronAppBasePath, + getElectronAppUnpackedBasePath, + getWaveConfigDir, + getWaveDataDir, + isDev, + unameArch, + unamePlatform, +} from "./emain-platform"; +import { ensureHotSpareTab, setMaxTabCacheSize } from "./emain-tabview"; +import { getIsWaveSrvDead, getWaveSrvProc, getWaveSrvReady, runWaveSrv } from "./emain-wavesrv"; +import { + createBrowserWindow, + createNewWaveWindow, + focusedWaveWindow, + getAllWaveWindows, + getQuakeWindow, + getWaveWindowById, + getWaveWindowByWorkspaceId, + initGlobalHotkeyEventSubscription, + registerGlobalHotkey, + relaunchBrowserWindows, + WaveBrowserWindow, +} from "./emain-window"; +import { ElectronWshClient, initElectronWshClient } from "./emain-wsh"; +import { getLaunchSettings } from "./launchsettings"; +import { configureAutoUpdater, updater } from "./updater"; + +const electronApp = electron.app; + +let confirmQuit = true; + +const waveDataDir = getWaveDataDir(); +const waveConfigDir = getWaveConfigDir(); + +electron.nativeTheme.themeSource = "dark"; + +console.log = log; +console.log( + sprintf( + "crest-app starting, data_dir=%s, config_dir=%s electronpath=%s gopath=%s arch=%s/%s electron=%s", + waveDataDir, + waveConfigDir, + getElectronAppBasePath(), + getElectronAppUnpackedBasePath(), + unamePlatform, + unameArch, + process.versions.electron + ) +); +if (isDev) { + console.log("crest-app WAVETERM_DEV set"); +} + +function handleWSEvent(evtMsg: WSEventType) { + fireAndForget(async () => { + console.log("handleWSEvent", evtMsg?.eventtype); + if (evtMsg.eventtype == "electron:newwindow") { + console.log("electron:newwindow", evtMsg.data); + const windowId: string = evtMsg.data; + const windowData: WaveWindow = (await services.ObjectService.GetObject("window:" + windowId)) as WaveWindow; + if (windowData == null) { + return; + } + const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); + const newWin = await createBrowserWindow(windowData, fullConfig, { + unamePlatform, + isPrimaryStartupWindow: false, + }); + newWin.show(); + } else if (evtMsg.eventtype == "electron:closewindow") { + console.log("electron:closewindow", evtMsg.data); + if (evtMsg.data === undefined) return; + const ww = getWaveWindowById(evtMsg.data); + if (ww != null) { + ww.destroy(); // bypass the "are you sure?" dialog + } + } else if (evtMsg.eventtype == "electron:updateactivetab") { + const activeTabUpdate: { workspaceid: string; newactivetabid: string } = evtMsg.data; + console.log("electron:updateactivetab", activeTabUpdate); + const ww = getWaveWindowByWorkspaceId(activeTabUpdate.workspaceid); + if (ww == null) { + return; + } + await ww.setActiveTab(activeTabUpdate.newactivetabid, false); + } else { + console.log("unhandled electron ws eventtype", evtMsg.eventtype); + } + }); +} + +// we try to set the primary display as index [0] +function getActivityDisplays(): ActivityDisplayType[] { + const displays = electron.screen.getAllDisplays(); + const primaryDisplay = electron.screen.getPrimaryDisplay(); + const rtn: ActivityDisplayType[] = []; + for (const display of displays) { + const adt = { + width: display.size.width, + height: display.size.height, + dpr: display.scaleFactor, + internal: display.internal, + }; + if (display.id === primaryDisplay?.id) { + rtn.unshift(adt); + } else { + rtn.push(adt); + } + } + return rtn; +} + +async function sendDisplaysTDataEvent() { + const displays = getActivityDisplays(); + if (displays.length === 0) { + return; + } + const props: TEventProps = {}; + props["display:count"] = displays.length; + props["display:height"] = displays[0].height; + props["display:width"] = displays[0].width; + props["display:dpr"] = displays[0].dpr; + props["display:all"] = displays; + try { + await RpcApi.RecordTEventCommand( + ElectronWshClient, + { + event: "app:display", + props, + }, + { noresponse: true } + ); + } catch (e) { + console.log("error sending display tdata event", e); + } +} + +function logActiveState() { + fireAndForget(async () => { + const astate = getActivityState(); + const activity: ActivityUpdate = { openminutes: 1 }; + const ww = focusedWaveWindow; + const activeTabView = ww?.activeTabView; + const isWaveAIOpen = activeTabView?.isWaveAIOpen ?? false; + + if (astate.wasInFg) { + activity.fgminutes = 1; + } + if (astate.wasActive) { + activity.activeminutes = 1; + } + activity.displays = getActivityDisplays(); + + const termCmdCount = getAndClearTermCommandsRun(); + if (termCmdCount > 0) { + activity.termcommandsrun = termCmdCount; + } + const termCmdRemoteCount = getAndClearTermCommandsRemote(); + const termCmdWslCount = getAndClearTermCommandsWsl(); + const termCmdDurableCount = getAndClearTermCommandsDurable(); + + const props: TEventProps = { + "activity:activeminutes": activity.activeminutes, + "activity:fgminutes": activity.fgminutes, + "activity:openminutes": activity.openminutes, + }; + if (termCmdCount > 0) { + props["activity:termcommandsrun"] = termCmdCount; + } + if (termCmdRemoteCount > 0) { + props["activity:termcommands:remote"] = termCmdRemoteCount; + } + if (termCmdWslCount > 0) { + props["activity:termcommands:wsl"] = termCmdWslCount; + } + if (termCmdDurableCount > 0) { + props["activity:termcommands:durable"] = termCmdDurableCount; + } + if (astate.wasActive && isWaveAIOpen) { + props["activity:waveaiactiveminutes"] = 1; + } + if (astate.wasInFg && isWaveAIOpen) { + props["activity:waveaifgminutes"] = 1; + } + + try { + await RpcApi.ActivityCommand(ElectronWshClient, activity, { noresponse: true }); + await RpcApi.RecordTEventCommand( + ElectronWshClient, + { + event: "app:activity", + props, + }, + { noresponse: true } + ); + } catch (e) { + console.log("error logging active state", e); + } finally { + setWasInFg(ww?.isFocused() ?? false); + setWasActive(false); + } + }); +} + +// this isn't perfect, but gets the job done without being complicated +function runActiveTimer() { + logActiveState(); + setTimeout(runActiveTimer, 60000); +} + +function hideWindowWithCatch(window: WaveBrowserWindow) { + if (window == null) { + return; + } + try { + if (window.isDestroyed()) { + return; + } + window.hide(); + } catch (e) { + console.log("error hiding window", e); + } +} + +electronApp.on("window-all-closed", () => { + if (getGlobalIsRelaunching()) { + return; + } + if (unamePlatform !== "darwin") { + setUserConfirmedQuit(true); + electronApp.quit(); + } +}); +electronApp.on("before-quit", (e) => { + const allWindows = getAllWaveWindows(); + const allBuilders = getAllBuilderWindows(); + if ( + confirmQuit && + !getForceQuit() && + !getUserConfirmedQuit() && + (allWindows.length > 0 || allBuilders.length > 0) && + !getIsWaveSrvDead() && + !process.env.WAVETERM_NOCONFIRMQUIT + ) { + e.preventDefault(); + const choice = electron.dialog.showMessageBoxSync(null, { + type: "question", + buttons: ["Cancel", "Quit"], + title: "Confirm Quit", + message: "Are you sure you want to quit Crest?", + defaultId: 0, + cancelId: 0, + }); + if (choice === 0) { + return; + } + setUserConfirmedQuit(true); + electronApp.quit(); + return; + } + setGlobalIsQuitting(true); + updater?.stop(); + if (unamePlatform == "win32") { + // win32 doesn't have a SIGINT, so we just let electron die, which + // ends up killing wavesrv via closing it's stdin. + return; + } + getWaveSrvProc()?.kill("SIGINT"); + shutdownWshrpc(); + if (getForceQuit()) { + return; + } + e.preventDefault(); + for (const window of allWindows) { + hideWindowWithCatch(window); + } + for (const builder of allBuilders) { + builder.hide(); + } + if (getIsWaveSrvDead()) { + console.log("wavesrv is dead, quitting immediately"); + setForceQuit(true); + electronApp.quit(); + return; + } + setTimeout(() => { + console.log("waiting for wavesrv to exit..."); + setForceQuit(true); + electronApp.quit(); + }, 3000); +}); +process.on("SIGINT", () => { + console.log("Caught SIGINT, shutting down"); + setUserConfirmedQuit(true); + electronApp.quit(); +}); +process.on("SIGHUP", () => { + console.log("Caught SIGHUP, shutting down"); + setUserConfirmedQuit(true); + electronApp.quit(); +}); +process.on("SIGTERM", () => { + console.log("Caught SIGTERM, shutting down"); + setUserConfirmedQuit(true); + electronApp.quit(); +}); +let caughtException = false; +process.on("uncaughtException", (error) => { + if (caughtException) { + return; + } + + // Check if the error is related to QUIC protocol, if so, ignore (can happen with the updater) + if (error?.message?.includes("net::ERR_QUIC_PROTOCOL_ERROR")) { + console.log("Ignoring QUIC protocol error:", error.message); + console.log("Stack Trace:", error.stack); + return; + } + + caughtException = true; + console.log("Uncaught Exception, shutting down: ", error); + console.log("Stack Trace:", error.stack); + // Optionally, handle cleanup or exit the app + setUserConfirmedQuit(true); + electronApp.quit(); +}); + +let lastWaveWindowCount = 0; +let lastIsBuilderWindowActive = false; +globalEvents.on("windows-updated", () => { + const wwCount = getAllWaveWindows().length; + const isBuilderActive = focusedBuilderWindow != null; + if (wwCount == lastWaveWindowCount && isBuilderActive == lastIsBuilderWindowActive) { + return; + } + lastWaveWindowCount = wwCount; + lastIsBuilderWindowActive = isBuilderActive; + console.log("windows-updated", wwCount, "builder-active:", isBuilderActive); + makeAndSetAppMenu(); +}); + +async function appMain() { + // Set disableHardwareAcceleration as early as possible, if required. + const launchSettings = getLaunchSettings(); + if (launchSettings?.["window:disablehardwareacceleration"]) { + console.log("disabling hardware acceleration, per launch settings"); + electronApp.disableHardwareAcceleration(); + } + const startTs = Date.now(); + const instanceLock = electronApp.requestSingleInstanceLock(); + if (!instanceLock) { + console.log("crest-app could not get single-instance-lock, shutting down"); + setUserConfirmedQuit(true); + electronApp.quit(); + return; + } + electronApp.on("second-instance", (_event, argv, workingDirectory) => { + console.log("second-instance event, argv:", argv, "workingDirectory:", workingDirectory); + fireAndForget(createNewWaveWindow); + }); + try { + await runWaveSrv(handleWSEvent); + } catch (e) { + console.log(e.toString()); + } + const ready = await getWaveSrvReady(); + console.log("wavesrv ready signal received", ready, Date.now() - startTs, "ms"); + await electronApp.whenReady(); + configureAuthKeyRequestInjection(electron.session.defaultSession); + initIpcHandlers(); + + await sleep(10); // wait a bit for wavesrv to be ready + try { + initElectronWshClient(); + initElectronWshrpc(ElectronWshClient, { authKey: AuthKey }); + initMenuEventSubscriptions(); + } catch (e) { + console.log("error initializing wshrpc", e); + } + const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); + checkIfRunningUnderARM64Translation(fullConfig); + if (fullConfig?.settings?.["app:confirmquit"] != null) { + confirmQuit = fullConfig.settings["app:confirmquit"]; + } + ensureHotSpareTab(fullConfig); + await relaunchBrowserWindows(); + setTimeout(runActiveTimer, 5000); // start active timer, wait 5s just to be safe + setTimeout(sendDisplaysTDataEvent, 5000); + + makeAndSetAppMenu(); + makeDockTaskbar(); + await configureAutoUpdater(); + setGlobalIsStarting(false); + if (fullConfig?.settings?.["window:maxtabcachesize"] != null) { + setMaxTabCacheSize(fullConfig.settings["window:maxtabcachesize"]); + } + + electronApp.on("activate", () => { + const allWindows = getAllWaveWindows(); + const anyVisible = allWindows.some((w) => !w.isDestroyed() && w.isVisible()); + if (anyVisible) { + return; + } + const qw = getQuakeWindow(); + if (qw != null && !qw.isDestroyed()) { + qw.show(); + qw.focus(); + return; + } + if (allWindows.length === 0) { + fireAndForget(createNewWaveWindow); + } + }); + electron.powerMonitor.on("resume", () => { + console.log("system resumed from sleep, notifying server"); + fireAndForget(async () => { + try { + await RpcApi.NotifySystemResumeCommand(ElectronWshClient, { noresponse: true }); + } catch (e) { + console.log("error calling NotifySystemResumeCommand", e); + } + }); + }); + const rawGlobalHotKey = launchSettings?.["app:globalhotkey"]; + if (rawGlobalHotKey) { + registerGlobalHotkey(rawGlobalHotKey); + } + initGlobalHotkeyEventSubscription(); +} + +appMain().catch((e) => { + console.log("appMain error", e); + setUserConfirmedQuit(true); + electronApp.quit(); +}); diff --git a/emain/launchsettings.ts b/emain/launchsettings.ts new file mode 100644 index 000000000..238c3a04a --- /dev/null +++ b/emain/launchsettings.ts @@ -0,0 +1,21 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "fs"; +import path from "path"; +import { getWaveConfigDir } from "./emain-platform"; + +/** + * Get settings directly from the Wave Home directory on launch. + * Only use this when the app is first starting up. Otherwise, prefer the settings.GetFullConfig function. + * @returns The initial launch settings for the application. + */ +export function getLaunchSettings(): SettingsType { + const settingsPath = path.join(getWaveConfigDir(), "settings.json"); + try { + const settingsContents = fs.readFileSync(settingsPath, "utf8"); + return JSON.parse(settingsContents); + } catch (_) { + // fail silently + } +} diff --git a/emain/preload-webview.ts b/emain/preload-webview.ts new file mode 100644 index 000000000..e2a39a3b4 --- /dev/null +++ b/emain/preload-webview.ts @@ -0,0 +1,39 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { ipcRenderer } from "electron"; + +document.addEventListener("contextmenu", (event) => { + console.log("contextmenu event", event); + if (event.target == null) { + return; + } + const targetElement = event.target as HTMLElement; + // Check if the right-click is on an image + if (targetElement.tagName === "IMG") { + setTimeout(() => { + if (event.defaultPrevented) { + return; + } + event.preventDefault(); + const imgElem = targetElement as HTMLImageElement; + const imageUrl = imgElem.src; + ipcRenderer.send("webview-image-contextmenu", { src: imageUrl }); + }, 50); + return; + } + // do nothing +}); + +document.addEventListener("mouseup", (event) => { + // Mouse button 3 = back, button 4 = forward + if (!event.isTrusted) { + return; + } + if (event.button === 3 || event.button === 4) { + event.preventDefault(); + ipcRenderer.send("webview-mouse-navigate", event.button === 3 ? "back" : "forward"); + } +}); + +console.log("loaded wave preload-webview.ts"); diff --git a/emain/preload.ts b/emain/preload.ts new file mode 100644 index 000000000..af2f2f525 --- /dev/null +++ b/emain/preload.ts @@ -0,0 +1,185 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { contextBridge, ipcRenderer, Rectangle, webUtils, WebviewTag } from "electron"; + +// Single shared dispatcher for directory-watch events (main fans them out here). +const dirWatchCallbacks = new Map void>>(); +ipcRenderer.on("dir-changed", (_event, path: string, eventType: string, filename: string) => { + const cbs = dirWatchCallbacks.get(path); + if (!cbs) return; + for (const cb of cbs) { + try { + cb(eventType, filename); + } catch (e) { + console.error("dir-changed callback error", e); + } + } +}); + +// Agent event fan-out — mirrors the dir-watch pattern. Main emits +// "agent:event" with {sessionPath, event}; we route to per-sessionPath +// callbacks the renderer registered via api.agent.subscribe(). +const agentEventCallbacks = new Map void>>(); +ipcRenderer.on( + "agent:event", + (_event, payload: { sessionPath: string; event: unknown }) => { + const cbs = agentEventCallbacks.get(payload.sessionPath); + if (!cbs) return; + for (const cb of cbs) { + try { + cb(payload.event); + } catch (e) { + console.error("agent:event callback error", e); + } + } + }, +); + +// update type in custom.d.ts (ElectronApi type) +contextBridge.exposeInMainWorld("api", { + getAuthKey: () => ipcRenderer.sendSync("get-auth-key"), + getIsDev: () => ipcRenderer.sendSync("get-is-dev"), + getPlatform: () => ipcRenderer.sendSync("get-platform"), + getCursorPoint: () => ipcRenderer.sendSync("get-cursor-point"), + getUserName: () => ipcRenderer.sendSync("get-user-name"), + getHostName: () => ipcRenderer.sendSync("get-host-name"), + getDataDir: () => ipcRenderer.sendSync("get-data-dir"), + getConfigDir: () => ipcRenderer.sendSync("get-config-dir"), + getHomeDir: () => ipcRenderer.sendSync("get-home-dir"), + getAboutModalDetails: () => ipcRenderer.sendSync("get-about-modal-details"), + getWebviewPreload: () => ipcRenderer.sendSync("get-webview-preload"), + getZoomFactor: () => ipcRenderer.sendSync("get-zoom-factor"), + getIsFullScreen: () => ipcRenderer.sendSync("get-is-full-screen"), + openNewWindow: () => ipcRenderer.send("open-new-window"), + showWorkspaceAppMenu: (workspaceId) => ipcRenderer.send("workspace-appmenu-show", workspaceId), + showBuilderAppMenu: (builderId) => ipcRenderer.send("builder-appmenu-show", builderId), + showContextMenu: (workspaceId, menu, position) => ipcRenderer.send("contextmenu-show", workspaceId, menu, position), + onContextMenuClick: (callback: (id: string | null) => void) => + ipcRenderer.on("contextmenu-click", (_event, id: string | null) => callback(id)), + downloadFile: (filePath) => ipcRenderer.send("download", { filePath }), + openExternal: (url) => { + if (url && typeof url === "string") { + ipcRenderer.send("open-external", url); + } else { + console.error("Invalid URL passed to openExternal:", url); + } + }, + getEnv: (varName) => ipcRenderer.sendSync("get-env", varName), + onFullScreenChange: (callback) => + ipcRenderer.on("fullscreen-change", (_event, isFullScreen) => callback(isFullScreen)), + onZoomFactorChange: (callback) => + ipcRenderer.on("zoom-factor-change", (_event, zoomFactor) => callback(zoomFactor)), + onUpdaterStatusChange: (callback) => ipcRenderer.on("app-update-status", (_event, status) => callback(status)), + getUpdaterStatus: () => ipcRenderer.sendSync("get-app-update-status"), + getUpdaterChannel: () => ipcRenderer.sendSync("get-updater-channel"), + installAppUpdate: () => ipcRenderer.send("install-app-update"), + onMenuItemAbout: (callback) => ipcRenderer.on("menu-item-about", callback), + updateWindowControlsOverlay: (rect) => ipcRenderer.send("update-window-controls-overlay", rect), + onReinjectKey: (callback) => ipcRenderer.on("reinject-key", (_event, waveEvent) => callback(waveEvent)), + setWebviewFocus: (focused: number) => ipcRenderer.send("webview-focus", focused), + registerGlobalWebviewKeys: (keys) => ipcRenderer.send("register-global-webview-keys", keys), + onControlShiftStateUpdate: (callback) => + ipcRenderer.on("control-shift-state-update", (_event, state) => callback(state)), + createWorkspace: () => ipcRenderer.send("create-workspace"), + switchWorkspace: (workspaceId) => ipcRenderer.send("switch-workspace", workspaceId), + deleteWorkspace: (workspaceId) => ipcRenderer.send("delete-workspace", workspaceId), + setActiveTab: (tabId) => ipcRenderer.send("set-active-tab", tabId), + createTab: () => ipcRenderer.send("create-tab"), + closeTab: (workspaceId, tabId, confirmClose) => ipcRenderer.invoke("close-tab", workspaceId, tabId, confirmClose), + setWindowInitStatus: (status) => ipcRenderer.send("set-window-init-status", status), + onWaveInit: (callback) => ipcRenderer.on("wave-init", (_event, initOpts) => callback(initOpts)), + onBuilderInit: (callback) => ipcRenderer.on("builder-init", (_event, initOpts) => callback(initOpts)), + sendLog: (log) => ipcRenderer.send("fe-log", log), + onQuicklook: (filePath: string) => ipcRenderer.send("quicklook", filePath), + openNativePath: (filePath: string) => ipcRenderer.send("open-native-path", filePath), + captureScreenshot: (rect: Rectangle) => ipcRenderer.invoke("capture-screenshot", rect), + setKeyboardChordMode: () => ipcRenderer.send("set-keyboard-chord-mode"), + clearWebviewStorage: (webContentsId: number) => ipcRenderer.invoke("clear-webview-storage", webContentsId), + setWaveAIOpen: (isOpen: boolean) => ipcRenderer.send("set-waveai-open", isOpen), + closeBuilderWindow: () => ipcRenderer.send("close-builder-window"), + incrementTermCommands: (opts?: { isRemote?: boolean; isWsl?: boolean; isDurable?: boolean }) => + ipcRenderer.send("increment-term-commands", opts), + nativePaste: () => ipcRenderer.send("native-paste"), + openBuilder: (appId?: string) => ipcRenderer.send("open-builder", appId), + setBuilderWindowAppId: (appId: string) => ipcRenderer.send("set-builder-window-appid", appId), + doRefresh: () => ipcRenderer.send("do-refresh"), + getPathForFile: (file: File): string => webUtils.getPathForFile(file), + saveTextFile: (fileName: string, content: string) => ipcRenderer.invoke("save-text-file", fileName, content), + setIsActive: () => ipcRenderer.invoke("set-is-active"), + watchDir: (path: string, callback: (eventType: string, filename: string) => void): void => { + // Register (or fan-out) a per-path callback. Main process de-duplicates at its end; + // we track callbacks per path so unwatchDir only removes the intended listener. + let entry = dirWatchCallbacks.get(path); + if (!entry) { + entry = new Set(); + dirWatchCallbacks.set(path, entry); + } + entry.add(callback); + ipcRenderer.send("watch-dir", path); + }, + unwatchDir: (path: string, callback?: (eventType: string, filename: string) => void): void => { + const entry = dirWatchCallbacks.get(path); + if (entry) { + if (callback) { + entry.delete(callback); + } else { + entry.clear(); + } + if (entry.size === 0) { + dirWatchCallbacks.delete(path); + ipcRenderer.send("unwatch-dir", path); + } + } else { + ipcRenderer.send("unwatch-dir", path); + } + }, + // ─── AI config / provider listing ──────────────────────────────── + // See emain/aiconfig-ipc.ts. Replaces the Go ListProviderModelsCommand. + ai: { + listProviderModels: (input: unknown) => + ipcRenderer.invoke("ai:list-provider-models", input), + getUserConfig: () => ipcRenderer.invoke("ai:get-user-config"), + writeUserConfig: (cfg: unknown) => ipcRenderer.invoke("ai:write-user-config", cfg), + }, + // ─── Agent runtime (Electron main agent loop) ──────────────────── + // See docs/agent-runtime-architecture.md §2 + emain/agent-ipc.ts. + agent: { + createSession: (cwd: string) => ipcRenderer.invoke("agent:create-session", cwd), + listSessionsForCwd: (cwd: string) => + ipcRenderer.invoke("agent:list-sessions-for-cwd", cwd), + send: (opts: unknown) => ipcRenderer.invoke("agent:send", opts), + abort: (sessionPath: string) => ipcRenderer.send("agent:abort", sessionPath), + subscribe: (sessionPath: string, callback: (event: unknown) => void): (() => void) => { + let entry = agentEventCallbacks.get(sessionPath); + if (!entry) { + entry = new Set(); + agentEventCallbacks.set(sessionPath, entry); + // First subscriber for this path → tell main to start forwarding. + ipcRenderer.send("agent:subscribe", sessionPath); + } + entry.add(callback); + return () => { + const cur = agentEventCallbacks.get(sessionPath); + if (!cur) return; + cur.delete(callback); + if (cur.size === 0) { + agentEventCallbacks.delete(sessionPath); + ipcRenderer.send("agent:unsubscribe", sessionPath); + } + }; + }, + }, +}); + +// Custom event for "new-window" +ipcRenderer.on("webview-new-window", (e, webContentsId, details) => { + const event = new CustomEvent("new-window", { detail: details }); + document.getElementById("webview").dispatchEvent(event); +}); + +ipcRenderer.on("webcontentsid-from-blockid", (e, blockId, responseCh) => { + const webviewElem: WebviewTag = document.querySelector("div[data-blockid='" + blockId + "'] webview"); + const wcId = webviewElem?.dataset?.webcontentsid; + ipcRenderer.send(responseCh, wcId); +}); diff --git a/emain/updater.ts b/emain/updater.ts new file mode 100644 index 000000000..8f06e6bec --- /dev/null +++ b/emain/updater.ts @@ -0,0 +1,253 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { dialog, ipcMain, Notification } from "electron"; +import { autoUpdater } from "electron-updater"; +import { readFileSync } from "fs"; +import path from "path"; +import YAML from "yaml"; +import { RpcApi } from "../frontend/app/store/wshclientapi"; +import { isDev } from "../frontend/util/isdev"; +import { fireAndForget } from "../frontend/util/util"; +import { setUserConfirmedQuit } from "./emain-activity"; +import { delay } from "./emain-util"; +import { focusedWaveWindow, getAllWaveWindows } from "./emain-window"; +import { ElectronWshClient } from "./emain-wsh"; + +export let updater: Updater; + +function getUpdateChannel(settings: SettingsType): string { + const updaterConfigPath = path.join(process.resourcesPath!, "app-update.yml"); + const updaterConfig = YAML.parse(readFileSync(updaterConfigPath, { encoding: "utf8" }).toString()); + console.log("Updater config from binary:", updaterConfig); + const updaterChannel: string = updaterConfig.channel ?? "latest"; + const settingsChannel = settings["autoupdate:channel"]; + let retVal = settingsChannel; + + // If the user setting doesn't exist yet, set it to the value of the updater config. + // If the user was previously on the `latest` channel and has downloaded a `beta` version, update their configured channel to `beta` to prevent downgrading. + if (!settingsChannel || (settingsChannel == "latest" && updaterChannel == "beta")) { + console.log("Update channel setting does not exist, setting to value from updater config."); + RpcApi.SetConfigCommand(ElectronWshClient, { "autoupdate:channel": updaterChannel }); + retVal = updaterChannel; + } + console.log("Update channel:", retVal); + return retVal; +} + +export class Updater { + autoCheckInterval: NodeJS.Timeout | null; + intervalms: number; + autoCheckEnabled: boolean; + availableUpdateReleaseName: string | null; + availableUpdateReleaseNotes: string | null; + private _status: UpdaterStatus; + lastUpdateCheck: Date; + + constructor(settings: SettingsType) { + this.intervalms = settings["autoupdate:intervalms"]; + console.log("Update check interval in milliseconds:", this.intervalms); + this.autoCheckEnabled = settings["autoupdate:enabled"]; + console.log("Update check enabled:", this.autoCheckEnabled); + + this._status = "up-to-date"; + this.lastUpdateCheck = new Date(0); + this.autoCheckInterval = null; + this.availableUpdateReleaseName = null; + + autoUpdater.autoInstallOnAppQuit = settings["autoupdate:installonquit"]; + console.log("Install update on quit:", settings["autoupdate:installonquit"]); + + // Only update the release channel if it's specified, otherwise use the one configured in the updater. + autoUpdater.channel = getUpdateChannel(settings); + autoUpdater.allowDowngrade = false; + + autoUpdater.removeAllListeners(); + + autoUpdater.on("error", (err) => { + console.log("updater error"); + console.log(err); + if (!err.toString()?.includes("net::ERR_INTERNET_DISCONNECTED")) this.status = "error"; + }); + + autoUpdater.on("checking-for-update", () => { + console.log("checking-for-update"); + this.status = "checking"; + }); + + autoUpdater.on("update-available", () => { + console.log("update-available; downloading..."); + this.status = "downloading"; + }); + + autoUpdater.on("update-not-available", () => { + console.log("update-not-available"); + this.status = "up-to-date"; + }); + + autoUpdater.on("update-downloaded", (event) => { + console.log("update-downloaded", [event]); + this.availableUpdateReleaseName = event.releaseName; + this.availableUpdateReleaseNotes = event.releaseNotes as string | null; + + // Display the update banner and create a system notification + this.status = "ready"; + const updateNotification = new Notification({ + title: "Wave Terminal", + body: "A new version of Wave Terminal is ready to install.", + }); + updateNotification.on("click", () => { + fireAndForget(this.promptToInstallUpdate.bind(this)); + }); + updateNotification.show(); + }); + } + + /** + * The status of the Updater. + */ + get status(): UpdaterStatus { + return this._status; + } + + private set status(value: UpdaterStatus) { + this._status = value; + getAllWaveWindows().forEach((window) => { + const allTabs = Array.from(window.allLoadedTabViews.values()); + allTabs.forEach((tab) => { + tab.webContents.send("app-update-status", value); + }); + }); + } + + /** + * Check for updates and start the background update check, if configured. + */ + async start() { + if (this.autoCheckEnabled) { + console.log("starting updater"); + this.autoCheckInterval = setInterval(() => { + fireAndForget(() => this.checkForUpdates(false)); + }, 600000); // intervals are unreliable when an app is suspended so we will check every 10 mins if the interval has passed. + await this.checkForUpdates(false); + } + } + + /** + * Stop the background update check, if configured. + */ + stop() { + console.log("stopping updater"); + if (this.autoCheckInterval) { + clearInterval(this.autoCheckInterval); + this.autoCheckInterval = null; + } + } + + /** + * Checks if the configured interval time has passed since the last update check, and if so, checks for updates using the `autoUpdater` object + * @param userInput Whether the user is requesting this. If so, an alert will report the result of the check. + */ + async checkForUpdates(userInput: boolean) { + const now = new Date(); + + // Run an update check always if the user requests it, otherwise only if there's an active update check interval and enough time has elapsed. + if ( + userInput || + (this.autoCheckInterval && + (!this.lastUpdateCheck || Math.abs(now.getTime() - this.lastUpdateCheck.getTime()) > this.intervalms)) + ) { + const result = await autoUpdater.checkForUpdates(); + + // If the user requested this check and we do not have an available update, let them know with a popup dialog. No need to tell them if there is an update, because we show a banner once the update is ready to install. + if (userInput && !result.downloadPromise) { + const dialogOpts: Electron.MessageBoxOptions = { + type: "info", + message: "There are currently no updates available.", + }; + if (focusedWaveWindow) { + dialog.showMessageBox(focusedWaveWindow, dialogOpts); + } + } + + // Only update the last check time if this is an automatic check. This ensures the interval remains consistent. + if (!userInput) this.lastUpdateCheck = now; + } + } + + /** + * Prompts the user to install the downloaded application update and restarts the application + */ + async promptToInstallUpdate() { + const dialogOpts: Electron.MessageBoxOptions = { + type: "info", + buttons: ["Restart", "Later"], + title: "Application Update", + message: process.platform === "win32" ? this.availableUpdateReleaseNotes : this.availableUpdateReleaseName, + detail: "A new version has been downloaded. Restart the application to apply the updates.", + }; + + const allWindows = getAllWaveWindows(); + if (allWindows.length > 0) { + await dialog.showMessageBox(focusedWaveWindow ?? allWindows[0], dialogOpts).then(({ response }) => { + if (response === 0) { + fireAndForget(this.installUpdate.bind(this)); + } + }); + } + } + + /** + * Restarts the app and installs an update if it is available. + */ + async installUpdate() { + if (this.status == "ready") { + this.status = "installing"; + await delay(1000); + setUserConfirmedQuit(true); + autoUpdater.quitAndInstall(); + } + } +} + +export function getResolvedUpdateChannel(): string { + return isDev() ? "dev" : (autoUpdater.channel ?? "latest"); +} + +ipcMain.on("install-app-update", () => fireAndForget(updater?.promptToInstallUpdate.bind(updater))); +ipcMain.on("get-app-update-status", (event) => { + event.returnValue = updater?.status; +}); +ipcMain.on("get-updater-channel", (event) => { + event.returnValue = getResolvedUpdateChannel(); +}); + +let autoUpdateLock = false; + +/** + * Configures the auto-updater based on the user's preference + */ +export async function configureAutoUpdater() { + if (isDev()) { + console.log("skipping auto-updater in dev mode"); + return; + } + + // simple lock to prevent multiple auto-update configuration attempts, this should be very rare + if (autoUpdateLock) { + console.log("auto-update configuration already in progress, skipping"); + return; + } + autoUpdateLock = true; + + try { + console.log("Configuring updater"); + const settings = (await RpcApi.GetFullConfigCommand(ElectronWshClient)).settings; + updater = new Updater(settings); + await updater.start(); + } catch (e) { + console.warn("error configuring updater", e.toString()); + } + + autoUpdateLock = false; +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 000000000..be5c3d3b7 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,90 @@ +// @ts-check + +import eslint from "@eslint/js"; +import eslintConfigPrettier from "eslint-config-prettier"; +import globals from "globals"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import tseslint from "typescript-eslint"; + +const tsconfigRootDir = path.dirname(fileURLToPath(new URL(import.meta.url))); + +export default [ + { + languageOptions: { + parserOptions: { + tsconfigRootDir, + }, + }, + }, + + { + ignores: [ + "**/node_modules/**", + "**/dist/**", + "**/build/**", + "**/make/**", + "tsunami/frontend/scaffold/**", + ], + }, + + { + files: ["frontend/**/*.{ts,tsx}", "emain/**/*.{ts,tsx}"], + languageOptions: { + parserOptions: { + tsconfigRootDir, + project: "./tsconfig.json", + }, + }, + }, + + eslint.configs.recommended, + ...tseslint.configs.recommended, + + { + rules: { + "@typescript-eslint/no-explicit-any": "off", + }, + }, + + { + files: ["emain/**/*.ts", "electron.vite.config.ts", "**/*.cjs", "eslint.config.js"], + languageOptions: { + globals: { + ...globals.node, + }, + }, + }, + + { + files: ["**/*.js", "**/*.cjs"], + rules: { + "@typescript-eslint/no-require-imports": "off", + }, + }, + + { + rules: { + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^(_[a-zA-Z0-9_]*|e|get)$", + varsIgnorePattern: "^(_[a-zA-Z0-9_]*|dlog|e)$", + caughtErrorsIgnorePattern: "^(_[a-zA-Z0-9_]*|e)$", + }, + ], + "prefer-const": "warn", + "no-empty": "warn", + }, + }, + + { + files: ["frontend/app/store/services.ts"], + rules: { + "@typescript-eslint/no-unused-vars": "off", + "prefer-rest-params": "off", + }, + }, + + eslintConfigPrettier, +]; diff --git a/eval/__init__.py b/eval/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/eval/harbor/README.md b/eval/harbor/README.md new file mode 100644 index 000000000..4605b3854 --- /dev/null +++ b/eval/harbor/README.md @@ -0,0 +1,45 @@ +# Crest Harbor Adapter + +Harbor installed-agent adapter for running Crest's native coding agent on [terminal-bench 2.0](https://tbench.ai). + +## Prerequisites + +- [Harbor](https://www.harborframework.com/) installed +- Docker running +- `ANTHROPIC_API_KEY` set (or your provider's API key) + +## Usage + +```bash +# Run with terminal-bench 2.0 +harbor run \ + -d terminal-bench/terminal-bench-2 \ + --agent-import-path eval.harbor.crest_agent:CrestAgent \ + -m anthropic/claude-sonnet-4-20250514 \ + -n 8 + +# Single task for testing +harbor run \ + -d terminal-bench/terminal-bench-2 \ + --agent-import-path eval.harbor.crest_agent:CrestAgent \ + -m anthropic/claude-sonnet-4-20250514 \ + -n 1 + +# Oracle baseline +harbor run -d terminal-bench/terminal-bench-2 -a oracle +``` + +## Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `ANTHROPIC_API_KEY` | API key for the AI provider | (required) | +| `HARBOR_MODEL` | Model to use | `anthropic/claude-sonnet-4-20250514` | +| `CREST_API_TYPE` | API type (`openai-chat`, `anthropic-messages`) | `openai-chat` | +| `CREST_BASE_URL` | Custom API base URL | (provider default) | + +## How It Works + +1. **Install**: Clones Crest, builds `wavesrv` (the Go backend) in the container +2. **Run**: Starts `wavesrv`, POSTs the task instruction to `/api/post-agent-message`, reads the SSE response stream +3. **Post-run**: Parses the SSE log into an ATIF trajectory for scoring diff --git a/eval/harbor/__init__.py b/eval/harbor/__init__.py new file mode 100644 index 000000000..719e617ff --- /dev/null +++ b/eval/harbor/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2026, Command Line Inc. +# SPDX-License-Identifier: Apache-2.0 diff --git a/eval/harbor/crest_agent.py b/eval/harbor/crest_agent.py new file mode 100644 index 000000000..29a7123eb --- /dev/null +++ b/eval/harbor/crest_agent.py @@ -0,0 +1,218 @@ +# Copyright 2026, Command Line Inc. +# SPDX-License-Identifier: Apache-2.0 + +""" +Harbor installed-agent adapter for Crest's native coding agent. + +Crest is a terminal application with an embedded coding agent. This adapter +installs the Crest server (`wavesrv`) in a Docker container and runs the +agent via its HTTP API (POST /api/post-agent-message), reading the SSE +response stream. + +Usage: + harbor run \\ + -d terminal-bench/terminal-bench-2 \\ + --agent-import-path eval.harbor.crest_agent:CrestAgent \\ + -m anthropic/claude-sonnet-4-20250514 \\ + -n 8 +""" + +import json +import os +import secrets +import shlex +import uuid + +from harbor.agents.installed.base import BaseInstalledAgent, with_prompt_template +from harbor.environments.base import BaseEnvironment +from harbor.models.agent.context import AgentContext + +CREST_REPO = "https://github.com/s-zx/crest.git" +CREST_BRANCH = "feat/native-agent" + +AGENT_API_PATH = "/api/post-agent-message" + + +def _detect_api_type(base_url: str) -> str: + url = base_url.lower() + if "anthropic.com" in url: + return "anthropic-messages" + if "generativelanguage.googleapis.com" in url: + return "google-gemini" + return "openai-chat" + + +class CrestAgent(BaseInstalledAgent): + """Harbor adapter that installs and runs Crest's native coding agent.""" + + @staticmethod + def name() -> str: + return "crest" + + def version(self) -> str | None: + return "0.1.0" + + async def install(self, environment: BaseEnvironment) -> None: + await self.exec_as_root( + environment, + command=( + "apt-get update && " + "apt-get install -y --no-install-recommends git curl build-essential ripgrep && " + "curl -fsSL https://go.dev/dl/go1.25.6.linux-amd64.tar.gz | tar -C /usr/local -xzf - && " + "ln -sf /usr/local/go/bin/go /usr/local/bin/go" + ), + ) + + await self.exec_as_root( + environment, + command=( + f"git clone --depth=1 --branch {CREST_BRANCH} {CREST_REPO} /tmp/crest && " + "cd /tmp/crest && " + "GOPATH=/tmp/gopath GOCACHE=/tmp/gocache go build -o /usr/local/bin/wavesrv ./cmd/server && " + "rm -rf /tmp/crest /tmp/gopath /tmp/gocache" + ), + ) + + await self.exec_as_agent( + environment, + command="wavesrv --version || echo 'wavesrv installed'", + ) + + @with_prompt_template + async def run( + self, + instruction: str, + environment: BaseEnvironment, + context: AgentContext, + ) -> None: + model = self.model_name or os.environ.get("HARBOR_MODEL", "anthropic/claude-sonnet-4-20250514") + api_key = os.environ.get("ANTHROPIC_API_KEY", "") or os.environ.get("OPENROUTER_API_KEY", "") + base_url = os.environ.get("CREST_BASE_URL", "") + api_type = os.environ.get("CREST_API_TYPE", "") + google_api_key = os.environ.get("GOOGLE_API_KEY", "") + + if google_api_key and model.startswith("google/"): + gemini_model = model.removeprefix("google/") + base_url = f"https://generativelanguage.googleapis.com/v1beta/models/{gemini_model}:streamGenerateContent" + api_type = "google-gemini" + api_key = google_api_key + model = gemini_model + elif not base_url: + base_url = "https://openrouter.ai/api/v1" + + if not api_type: + api_type = _detect_api_type(base_url) + + auth_key = secrets.token_hex(32) + env_vars = { + "WAVETERM_DEV": "1", + "WAVETERM_AUTH_KEY": auth_key, + "WAVETERM_CONFIG_HOME": "/home/agent/.config/waveterm", + "WAVETERM_DATA_HOME": "/home/agent/.local/share/waveterm", + } + env_export = " ".join(f"{k}={shlex.quote(v)}" for k, v in env_vars.items()) + + settings_json = json.dumps({ + "ai:apitype": api_type, + "ai:baseurl": base_url, + "ai:apitoken": api_key, + "ai:model": model, + }) + + chat_id = str(uuid.uuid4()) + message_id = str(uuid.uuid4()) + + request_body = json.dumps({ + "chatid": chat_id, + "tabid": "", + "blockid": "", + "mode": "bench", + "aimode": "", + "msg": { + "messageid": message_id, + "parts": [{"type": "text", "text": instruction}], + }, + "context": { + "cwd": os.environ.get("HARBOR_TASK_DIR", "/home/agent"), + }, + }) + + setup_cmd = ( + f"mkdir -p /home/agent/.config/waveterm /home/agent/.local/share/waveterm && " + f"echo {shlex.quote(settings_json)} > /home/agent/.config/waveterm/settings.json" + ) + + agent_cmd = ( + f"mkdir -p /logs/agent && " + f"tail -f /dev/null | {env_export} wavesrv > /logs/agent/wavesrv.log 2>&1 & " + f"WAVESRV_PID=$! && " + f"echo 'wavesrv started pid='$WAVESRV_PID && " + f"WEB_ADDR='' && " + f"for i in $(seq 1 30); do " + f" ESTART=$(grep 'WAVESRV-ESTART' /logs/agent/wavesrv.log 2>/dev/null || true); " + f" if [ -n \"$ESTART\" ]; then " + f" WEB_ADDR=$(echo \"$ESTART\" | sed -n 's/.*web:\\([^ ]*\\).*/\\1/p'); " + f" break; " + f" fi; " + f" sleep 1; " + f"done && " + f"if [ -z \"$WEB_ADDR\" ]; then " + f" echo 'ERROR: wavesrv failed to start'; " + f" cat /logs/agent/wavesrv.log; " + f" kill $WAVESRV_PID 2>/dev/null || true; " + f" exit 1; " + f"fi && " + f"echo 'wavesrv web at '$WEB_ADDR && " + f"curl -s -N --max-time 1800 -X POST http://$WEB_ADDR{AGENT_API_PATH} " + f"-H 'Content-Type: application/json' " + f"-H 'X-AuthKey: {auth_key}' " + f"-d {shlex.quote(request_body)} " + f"2>/logs/agent/curl-stderr.txt " + f"| tee /logs/agent/crest-agent.txt; " + f"kill $WAVESRV_PID 2>/dev/null || true" + ) + + await self.exec_as_agent(environment, command=setup_cmd) + await self.exec_as_agent(environment, command=agent_cmd) + + task_dir = os.environ.get("HARBOR_TASK_DIR", "/home/agent") + snapshot_cmd = ( + f"mkdir -p /logs/agent/workspace && " + f"find {task_dir} -maxdepth 3 -type f " + f" -not -path '*/node_modules/*' -not -path '*/.git/*' -not -name '*.ckpt' -not -name '*.bin' " + f" -size -100k " + f" -exec cp --parents {{}} /logs/agent/workspace/ \\; 2>/dev/null || true" + ) + await self.exec_as_agent(environment, command=snapshot_cmd) + + def populate_context_post_run(self, context: AgentContext) -> None: + log_path = "/logs/agent/crest-agent.txt" + if not os.path.exists(log_path): + return + + trajectory = [] + try: + with open(log_path) as f: + for line in f: + line = line.strip() + if not line or not line.startswith("data:"): + continue + data_str = line[len("data:"):].strip() + if not data_str: + continue + try: + event = json.loads(data_str) + trajectory.append(event) + except json.JSONDecodeError: + continue + except Exception: + pass + + if trajectory: + trajectory_path = "/logs/agent/trajectory.json" + with open(trajectory_path, "w") as f: + json.dump( + {"schema": "atif-v1.2", "events": trajectory}, + f, + indent=2, + ) diff --git a/eval/harbor/prompt_template.md b/eval/harbor/prompt_template.md new file mode 100644 index 000000000..1524271fc --- /dev/null +++ b/eval/harbor/prompt_template.md @@ -0,0 +1,12 @@ +You are a coding agent operating inside a terminal environment. Complete the following task. + +## Task + +{{ instruction }} + +## Guidelines + +- You have full access to the filesystem and shell commands. +- Work in the current directory unless the task specifies otherwise. +- After making changes, verify your work by running tests or checking the output. +- Do not ask clarifying questions — interpret the task to the best of your ability. diff --git a/frontend/app/app-bg.tsx b/frontend/app/app-bg.tsx new file mode 100644 index 000000000..2956e36d5 --- /dev/null +++ b/frontend/app/app-bg.tsx @@ -0,0 +1,62 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { MetaKeyAtomFnType, useWaveEnv, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; +import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; +import { computeBgStyleFromMeta } from "@/util/waveutil"; +import useResizeObserver from "@react-hook/resize-observer"; +import { useAtomValue } from "jotai"; +import { CSSProperties, useCallback, useLayoutEffect, useRef } from "react"; +import { debounce } from "throttle-debounce"; +import { atoms, getApi, WOS } from "./store/global"; +import { useWaveObjectValue } from "./store/wos"; + +type AppBgEnv = WaveEnvSubset<{ + getTabMetaKeyAtom: MetaKeyAtomFnType<"tab:background">; + getConfigBackgroundAtom: WaveEnv["getConfigBackgroundAtom"]; +}>; + +export function AppBackground() { + const bgRef = useRef(null); + const tabId = useAtomValue(atoms.staticTabId); + const [tabData] = useWaveObjectValue(WOS.makeORef("tab", tabId)); + const env = useWaveEnv(); + const tabBg = useAtomValue(env.getTabMetaKeyAtom(tabId, "tab:background")); + const configBg = useAtomValue(env.getConfigBackgroundAtom(tabBg)); + const resolvedMeta: Omit = tabBg && configBg ? configBg : tabData?.meta; + const style: CSSProperties = computeBgStyleFromMeta(resolvedMeta, 0.5) ?? {}; + const getAvgColor = useCallback( + debounce(30, () => { + if ( + bgRef.current && + PLATFORM !== PlatformMacOS && + bgRef.current && + "windowControlsOverlay" in window.navigator + ) { + const titlebarRect: Dimensions = (window.navigator.windowControlsOverlay as any).getTitlebarAreaRect(); + const bgRect = bgRef.current.getBoundingClientRect(); + if (titlebarRect && bgRect) { + const windowControlsLeft = titlebarRect.width - titlebarRect.height; + const windowControlsRect: Dimensions = { + top: titlebarRect.top, + left: windowControlsLeft, + height: titlebarRect.height, + width: bgRect.width - bgRect.left - windowControlsLeft, + }; + getApi().updateWindowControlsOverlay(windowControlsRect); + } + } + }), + [bgRef, style] + ); + useLayoutEffect(getAvgColor, [getAvgColor]); + useResizeObserver(bgRef, getAvgColor); + + return ( +
+ ); +} diff --git a/frontend/app/app.scss b/frontend/app/app.scss new file mode 100644 index 000000000..a72fd6ee9 --- /dev/null +++ b/frontend/app/app.scss @@ -0,0 +1,105 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +@use "reset.scss"; +@use "theme.scss"; + +html { + overflow: hidden; +} + +body { + display: flex; + flex-direction: row; + width: 100vw; + height: 100vh; + color: var(--main-text-color); + font: var(--base-font); + overflow: hidden; + /* Theme background — ThemeModel writes a solid into --color-background + and only sets --bg-gradient for gradient themes (e.g. Cyber Wave). + The user's window:bgcolor override still wins when set, via the + outer rgb(from ...) call. background-image rides on top so a + gradient theme paints over the solid color underneath. */ + background-color: rgb(from var(--main-bg-color, var(--color-background)) r g b / var(--window-opacity, 1)); + background-image: var(--bg-gradient, none); + -webkit-font-smoothing: auto; + backface-visibility: hidden; + transform: translateZ(0); +} + +.is-transparent { + background-color: transparent; + background-image: none; +} + +*::-webkit-scrollbar { + width: 4px; + height: 4px; +} + +*::-webkit-scrollbar-track { + background-color: var(--scrollbar-background-color); +} + +*::-webkit-scrollbar-thumb { + background-color: var(--scrollbar-thumb-color); + border-radius: 4px; + margin: 0 1px 0 1px; +} + +*::-webkit-scrollbar-thumb:hover { + background-color: var(--scrollbar-thumb-hover-color); +} + +.flex-spacer { + flex-grow: 1; +} + +.text-fixed { + font: var(--fixed-font); +} + +.error-boundary { + white-space: pre-wrap; + color: var(--error-color); +} + +.error-color { + color: var(--error-color); +} + +/* OverlayScrollbars styling */ +.os-scrollbar { + --os-handle-bg: var(--scrollbar-thumb-color); + --os-handle-bg-hover: var(--scrollbar-thumb-hover-color); + --os-handle-bg-active: var(--scrollbar-thumb-active-color); +} + +.scrollbar-hide-until-hover { + *::-webkit-scrollbar-thumb, + *::-webkit-scrollbar-track { + display: none; + } + + *::-webkit-scrollbar-corner { + display: none; + } + + *:hover::-webkit-scrollbar-thumb { + display: block; + } +} + +a { + color: var(--accent-color); +} + +.prefers-reduced-motion { + * { + transition-duration: none !important; + transition-timing-function: none !important; + transition-property: none !important; + transition-delay: none !important; + } +} diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx new file mode 100644 index 000000000..afd466984 --- /dev/null +++ b/frontend/app/app.tsx @@ -0,0 +1,388 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { + clearBadgesForBlockOnFocus, + clearBadgesForTabOnFocus, + getBadgeAtom, + getBlockBadgeAtom, +} from "@/app/store/badge"; +import { ClientModel } from "@/app/store/client-model"; +import { FocusManager } from "@/app/store/focusManager"; +import { GlobalModel } from "@/app/store/global-model"; +import { globalStore } from "@/app/store/jotaiStore"; +import { getTabModelByTabId, TabModelContext } from "@/app/store/tab-model"; +import { WaveEnvContext } from "@/app/waveenv/waveenv"; +import { makeWaveEnvImpl } from "@/app/waveenv/waveenvimpl"; +import { Workspace } from "@/app/workspace/workspace"; +import { getLayoutModelForStaticTab } from "@/layout/index"; +import { ContextMenuModel } from "@/store/contextmenu"; +import { atoms, createBlock, getSettingsPrefixAtom, refocusNode } from "@/store/global"; +import { appHandleKeyDown, keyboardMouseDownHandler } from "@/store/keymodel"; +import { getElemAsStr } from "@/util/focusutil"; +import * as keyutil from "@/util/keyutil"; +import { PLATFORM } from "@/util/platformutil"; +import * as util from "@/util/util"; +import clsx from "clsx"; +import debug from "debug"; +import { Provider, useAtomValue } from "jotai"; +import "overlayscrollbars/overlayscrollbars.css"; +import { useEffect, useRef } from "react"; +import { DndProvider } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; +import { AppBackground } from "./app-bg"; +import { CenteredDiv } from "./element/quickelems"; + +import "./app.scss"; + +// tailwindsetup.css should come *after* app.scss (don't remove the newline above otherwise prettier will reorder these imports) +import "../tailwindsetup.css"; + +const dlog = debug("wave:app"); +const focusLog = debug("wave:focus"); + +const App = ({ onFirstRender }: { onFirstRender: () => void }) => { + const tabId = useAtomValue(atoms.staticTabId); + const waveEnvRef = useRef(makeWaveEnvImpl()); + useEffect(() => { + onFirstRender(); + }, []); + return ( + + + + + + + + ); +}; + +function isContentEditableBeingEdited(): boolean { + const activeElement = document.activeElement; + return ( + activeElement && + activeElement.getAttribute("contenteditable") !== null && + activeElement.getAttribute("contenteditable") !== "false" + ); +} + +function canEnablePaste(): boolean { + const activeElement = document.activeElement; + return activeElement.tagName === "INPUT" || activeElement.tagName === "TEXTAREA" || isContentEditableBeingEdited(); +} + +function canEnableCopy(): boolean { + const sel = window.getSelection(); + return !util.isBlank(sel?.toString()); +} + +function canEnableCut(): boolean { + const sel = window.getSelection(); + if (document.activeElement?.classList.contains("xterm-helper-textarea")) { + return false; + } + return !util.isBlank(sel?.toString()) && canEnablePaste(); +} + +async function getClipboardURL(): Promise { + try { + const clipboardText = await navigator.clipboard.readText(); + if (clipboardText == null) { + return null; + } + const url = new URL(clipboardText); + if (!url.protocol.startsWith("http")) { + return null; + } + return url; + } catch (e) { + return null; + } +} + +async function handleContextMenu(e: React.MouseEvent) { + e.preventDefault(); + const canPaste = canEnablePaste(); + const canCopy = canEnableCopy(); + const canCut = canEnableCut(); + const clipboardURL = await getClipboardURL(); + if (!canPaste && !canCopy && !canCut && !clipboardURL) { + return; + } + const menu: ContextMenuItem[] = []; + if (canCut) { + menu.push({ label: "Cut", role: "cut" }); + } + if (canCopy) { + menu.push({ label: "Copy", role: "copy" }); + } + if (canPaste) { + menu.push({ label: "Paste", role: "paste" }); + } + if (clipboardURL) { + menu.push({ type: "separator" }); + menu.push({ + label: "Open Clipboard URL (" + clipboardURL.hostname + ")", + click: () => { + createBlock({ + meta: { + view: "web", + url: clipboardURL.toString(), + }, + }); + }, + }); + } + ContextMenuModel.getInstance().showContextMenu(menu, e); +} + +function AppSettingsUpdater() { + const windowSettingsAtom = getSettingsPrefixAtom("window"); + const windowSettings = useAtomValue(windowSettingsAtom); + useEffect(() => { + const isTransparentOrBlur = + (windowSettings?.["window:transparent"] || windowSettings?.["window:blur"]) ?? false; + const opacity = util.boundNumber(windowSettings?.["window:opacity"] ?? 0.8, 0, 1); + const baseBgColor = windowSettings?.["window:bgcolor"]; + const mainDiv = document.getElementById("main"); + // console.log("window settings", windowSettings, isTransparentOrBlur, opacity, baseBgColor, mainDiv); + if (isTransparentOrBlur) { + mainDiv.classList.add("is-transparent"); + if (opacity != null) { + document.body.style.setProperty("--window-opacity", `${opacity}`); + } else { + document.body.style.removeProperty("--window-opacity"); + } + } else { + mainDiv.classList.remove("is-transparent"); + document.body.style.removeProperty("--window-opacity"); + } + if (baseBgColor != null) { + document.body.style.setProperty("--main-bg-color", baseBgColor); + } else { + document.body.style.removeProperty("--main-bg-color"); + } + }, [windowSettings]); + return null; +} + +function appFocusIn(e: FocusEvent) { + focusLog("focusin", getElemAsStr(e.target), "<=", getElemAsStr(e.relatedTarget)); +} + +function appFocusOut(e: FocusEvent) { + focusLog("focusout", getElemAsStr(e.target), "=>", getElemAsStr(e.relatedTarget)); +} + +function appSelectionChange(e: Event) { + const selection = document.getSelection(); + focusLog("selectionchange", getElemAsStr(selection.anchorNode)); +} + +function AppFocusHandler() { + return null; + + // for debugging + useEffect(() => { + document.addEventListener("focusin", appFocusIn); + document.addEventListener("focusout", appFocusOut); + document.addEventListener("selectionchange", appSelectionChange); + const ivId = setInterval(() => { + const activeElement = document.activeElement; + if (activeElement instanceof HTMLElement) { + focusLog("activeElement", getElemAsStr(activeElement)); + } + }, 2000); + return () => { + document.removeEventListener("focusin", appFocusIn); + document.removeEventListener("focusout", appFocusOut); + document.removeEventListener("selectionchange", appSelectionChange); + clearInterval(ivId); + }; + }); + return null; +} + +const MacOSFirstClickHandler = () => { + useEffect(() => { + if (PLATFORM !== "darwin") { + return; + } + let windowFocusTime: number = null; + let cancelNextClick = false; + const handleWindowFocus = (e: FocusEvent) => { + windowFocusTime = Date.now(); + }; + const getBlockIdFromTarget = (target: EventTarget): string => { + let elem = target as HTMLElement; + while (elem != null) { + const blockId = elem.dataset?.blockid; + if (blockId) { + return blockId; + } + elem = elem.parentElement; + } + return null; + }; + const isAIPanelTarget = (target: EventTarget): boolean => { + let elem = target as HTMLElement; + while (elem != null) { + if (elem.dataset?.aipanel) { + return true; + } + elem = elem.parentElement; + } + return false; + }; + const handleMouseDown = (e: MouseEvent) => { + const timeDiff = Date.now() - windowFocusTime; + if (windowFocusTime != null && timeDiff < 50) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + cancelNextClick = true; + const blockId = getBlockIdFromTarget(e.target); + if (blockId != null) { + setTimeout(() => { + console.log("macos first-click, focusing block", blockId); + refocusNode(blockId); + }, 10); + } + console.log("macos first-click detected, canceled", timeDiff + "ms"); + return; + } + cancelNextClick = false; + }; + const handleClick = (e: MouseEvent) => { + if (!cancelNextClick) { + return; + } + cancelNextClick = false; + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + console.log("macos first-click (click event) canceled"); + }; + window.addEventListener("focus", handleWindowFocus); + window.addEventListener("mousedown", handleMouseDown, true); + window.addEventListener("click", handleClick, true); + return () => { + window.removeEventListener("focus", handleWindowFocus); + window.removeEventListener("mousedown", handleMouseDown, true); + window.removeEventListener("click", handleClick, true); + }; + }, []); + return null; +}; + +const AppKeyHandlers = () => { + useEffect(() => { + const staticKeyDownHandler = keyutil.keydownWrapper(appHandleKeyDown); + const staticMouseDownHandler = (e: MouseEvent) => { + keyboardMouseDownHandler(e); + GlobalModel.getInstance().setIsActive(); + }; + document.addEventListener("keydown", staticKeyDownHandler); + document.addEventListener("mousedown", staticMouseDownHandler); + + return () => { + document.removeEventListener("keydown", staticKeyDownHandler); + document.removeEventListener("mousedown", staticMouseDownHandler); + }; + }, []); + return null; +}; + +const BadgeAutoClearing = () => { + const tabId = useAtomValue(atoms.staticTabId); + const documentHasFocus = useAtomValue(atoms.documentHasFocus); + const layoutModel = getLayoutModelForStaticTab(); + const focusedNode = useAtomValue(layoutModel.focusedNode); + const focusedBlockId = focusedNode?.data?.blockId; + const badge = useAtomValue(getBlockBadgeAtom(focusedBlockId)); + const tabTransientBadge = useAtomValue(getBadgeAtom(tabId != null ? `tab:${tabId}` : null)); + const prevFocusedBlockIdRef = useRef(null); + const prevDocHasFocusRef = useRef(false); + const prevTabDocHasFocusRef = useRef(false); + + useEffect(() => { + if (!focusedBlockId || !badge || !documentHasFocus) { + prevFocusedBlockIdRef.current = focusedBlockId; + prevDocHasFocusRef.current = documentHasFocus; + return; + } + const focusSwitched = + prevFocusedBlockIdRef.current !== focusedBlockId || prevDocHasFocusRef.current !== documentHasFocus; + prevFocusedBlockIdRef.current = focusedBlockId; + prevDocHasFocusRef.current = documentHasFocus; + const delay = focusSwitched ? 500 : 3000; + const timeoutId = setTimeout(() => { + if (!document.hasFocus()) { + return; + } + const currentFocusedNode = globalStore.get(layoutModel.focusedNode); + if (currentFocusedNode?.data?.blockId === focusedBlockId) { + clearBadgesForBlockOnFocus(focusedBlockId); + } + }, delay); + return () => clearTimeout(timeoutId); + }, [focusedBlockId, badge, documentHasFocus]); + + useEffect(() => { + if (!tabId || !tabTransientBadge || !documentHasFocus) { + prevTabDocHasFocusRef.current = documentHasFocus; + return; + } + const focusSwitched = prevTabDocHasFocusRef.current !== documentHasFocus; + prevTabDocHasFocusRef.current = documentHasFocus; + const delay = focusSwitched ? 500 : 3000; + const timeoutId = setTimeout(() => { + if (!document.hasFocus()) { + return; + } + clearBadgesForTabOnFocus(tabId); + }, delay); + return () => clearTimeout(timeoutId); + }, [tabId, tabTransientBadge, documentHasFocus]); + + return null; +}; + +const AppInner = () => { + const prefersReducedMotion = useAtomValue(atoms.prefersReducedMotionAtom); + const client = useAtomValue(ClientModel.getInstance().clientAtom); + const windowData = useAtomValue(GlobalModel.getInstance().windowDataAtom); + const isFullScreen = useAtomValue(atoms.isFullScreen); + + if (client == null || windowData == null) { + return ( +
+ + invalid configuration, client or window was not loaded +
+ ); + } + + return ( +
+ + + + + + + + + +
+ ); +}; + +export { App }; diff --git a/frontend/app/asset/claude-color.svg b/frontend/app/asset/claude-color.svg new file mode 100644 index 000000000..b70e16774 --- /dev/null +++ b/frontend/app/asset/claude-color.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/app/asset/dots-anim-4.svg b/frontend/app/asset/dots-anim-4.svg new file mode 100644 index 000000000..028aaf349 --- /dev/null +++ b/frontend/app/asset/dots-anim-4.svg @@ -0,0 +1,18 @@ + +dots anim 4 + + + + + + + + + \ No newline at end of file diff --git a/frontend/app/asset/logo.svg b/frontend/app/asset/logo.svg new file mode 100644 index 000000000..51c1d820d --- /dev/null +++ b/frontend/app/asset/logo.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/app/asset/magnify-disabled.svg b/frontend/app/asset/magnify-disabled.svg new file mode 100644 index 000000000..1dbe4231a --- /dev/null +++ b/frontend/app/asset/magnify-disabled.svg @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/frontend/app/asset/magnify.svg b/frontend/app/asset/magnify.svg new file mode 100644 index 000000000..09a4919ca --- /dev/null +++ b/frontend/app/asset/magnify.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/frontend/app/asset/thunder.svg b/frontend/app/asset/thunder.svg new file mode 100644 index 000000000..67bce33f9 --- /dev/null +++ b/frontend/app/asset/thunder.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/app/asset/ui-icons/alert-circle.svg b/frontend/app/asset/ui-icons/alert-circle.svg new file mode 100644 index 000000000..1a2d8047c --- /dev/null +++ b/frontend/app/asset/ui-icons/alert-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/alert-hexagon.svg b/frontend/app/asset/ui-icons/alert-hexagon.svg new file mode 100644 index 000000000..41e264bd2 --- /dev/null +++ b/frontend/app/asset/ui-icons/alert-hexagon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/alert-triangle.svg b/frontend/app/asset/ui-icons/alert-triangle.svg new file mode 100644 index 000000000..9585f1332 --- /dev/null +++ b/frontend/app/asset/ui-icons/alert-triangle.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/book-open.svg b/frontend/app/asset/ui-icons/book-open.svg new file mode 100644 index 000000000..c7cfe02f3 --- /dev/null +++ b/frontend/app/asset/ui-icons/book-open.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/bookmark.svg b/frontend/app/asset/ui-icons/bookmark.svg new file mode 100644 index 000000000..f7993a13d --- /dev/null +++ b/frontend/app/asset/ui-icons/bookmark.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/bookmark_filled.svg b/frontend/app/asset/ui-icons/bookmark_filled.svg new file mode 100644 index 000000000..f63b8855c --- /dev/null +++ b/frontend/app/asset/ui-icons/bookmark_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/check-circle-broken.svg b/frontend/app/asset/ui-icons/check-circle-broken.svg new file mode 100644 index 000000000..13feb21b3 --- /dev/null +++ b/frontend/app/asset/ui-icons/check-circle-broken.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/check-thick.svg b/frontend/app/asset/ui-icons/check-thick.svg new file mode 100644 index 000000000..28e0e9e23 --- /dev/null +++ b/frontend/app/asset/ui-icons/check-thick.svg @@ -0,0 +1,5 @@ + + + diff --git a/frontend/app/asset/ui-icons/check.svg b/frontend/app/asset/ui-icons/check.svg new file mode 100644 index 000000000..07554eba1 --- /dev/null +++ b/frontend/app/asset/ui-icons/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/chevron-down-skinny.svg b/frontend/app/asset/ui-icons/chevron-down-skinny.svg new file mode 100644 index 000000000..a439c182e --- /dev/null +++ b/frontend/app/asset/ui-icons/chevron-down-skinny.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/chevron-down.svg b/frontend/app/asset/ui-icons/chevron-down.svg new file mode 100644 index 000000000..fa6dc07b6 --- /dev/null +++ b/frontend/app/asset/ui-icons/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/chevron-left-double.svg b/frontend/app/asset/ui-icons/chevron-left-double.svg new file mode 100644 index 000000000..87eb2c810 --- /dev/null +++ b/frontend/app/asset/ui-icons/chevron-left-double.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/chevron-left.svg b/frontend/app/asset/ui-icons/chevron-left.svg new file mode 100644 index 000000000..76fdbcb37 --- /dev/null +++ b/frontend/app/asset/ui-icons/chevron-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/chevron-right-double.svg b/frontend/app/asset/ui-icons/chevron-right-double.svg new file mode 100644 index 000000000..ed7ab0b4d --- /dev/null +++ b/frontend/app/asset/ui-icons/chevron-right-double.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/chevron-right-skinny.svg b/frontend/app/asset/ui-icons/chevron-right-skinny.svg new file mode 100644 index 000000000..4a200e9e6 --- /dev/null +++ b/frontend/app/asset/ui-icons/chevron-right-skinny.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/chevron-right.svg b/frontend/app/asset/ui-icons/chevron-right.svg new file mode 100644 index 000000000..4c97ab2e4 --- /dev/null +++ b/frontend/app/asset/ui-icons/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/chevron-up.svg b/frontend/app/asset/ui-icons/chevron-up.svg new file mode 100644 index 000000000..3add6aba5 --- /dev/null +++ b/frontend/app/asset/ui-icons/chevron-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/claude.svg b/frontend/app/asset/ui-icons/claude.svg new file mode 100644 index 000000000..61c2392b3 --- /dev/null +++ b/frontend/app/asset/ui-icons/claude.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/app/asset/ui-icons/clock-loader.svg b/frontend/app/asset/ui-icons/clock-loader.svg new file mode 100644 index 000000000..3d5511aac --- /dev/null +++ b/frontend/app/asset/ui-icons/clock-loader.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/clock-plus.svg b/frontend/app/asset/ui-icons/clock-plus.svg new file mode 100644 index 000000000..56c1810f6 --- /dev/null +++ b/frontend/app/asset/ui-icons/clock-plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/clock-rewind.svg b/frontend/app/asset/ui-icons/clock-rewind.svg new file mode 100644 index 000000000..fb67e91bd --- /dev/null +++ b/frontend/app/asset/ui-icons/clock-rewind.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/clock.svg b/frontend/app/asset/ui-icons/clock.svg new file mode 100644 index 000000000..b674c7c52 --- /dev/null +++ b/frontend/app/asset/ui-icons/clock.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/code-02.svg b/frontend/app/asset/ui-icons/code-02.svg new file mode 100644 index 000000000..8d1764a60 --- /dev/null +++ b/frontend/app/asset/ui-icons/code-02.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/compass-3.svg b/frontend/app/asset/ui-icons/compass-3.svg new file mode 100644 index 000000000..d39ca1b9a --- /dev/null +++ b/frontend/app/asset/ui-icons/compass-3.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/copy-05.svg b/frontend/app/asset/ui-icons/copy-05.svg new file mode 100644 index 000000000..528657144 --- /dev/null +++ b/frontend/app/asset/ui-icons/copy-05.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/copy-07.svg b/frontend/app/asset/ui-icons/copy-07.svg new file mode 100644 index 000000000..4b9133a07 --- /dev/null +++ b/frontend/app/asset/ui-icons/copy-07.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/copy.svg b/frontend/app/asset/ui-icons/copy.svg new file mode 100644 index 000000000..d8afed614 --- /dev/null +++ b/frontend/app/asset/ui-icons/copy.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/app/asset/ui-icons/dots-horizontal.svg b/frontend/app/asset/ui-icons/dots-horizontal.svg new file mode 100644 index 000000000..004860790 --- /dev/null +++ b/frontend/app/asset/ui-icons/dots-horizontal.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/dots-vertical.svg b/frontend/app/asset/ui-icons/dots-vertical.svg new file mode 100644 index 000000000..6cbe37086 --- /dev/null +++ b/frontend/app/asset/ui-icons/dots-vertical.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/ellipse.svg b/frontend/app/asset/ui-icons/ellipse.svg new file mode 100644 index 000000000..dc4e04c0d --- /dev/null +++ b/frontend/app/asset/ui-icons/ellipse.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/app/asset/ui-icons/env-var-collection.svg b/frontend/app/asset/ui-icons/env-var-collection.svg new file mode 100644 index 000000000..3304a169a --- /dev/null +++ b/frontend/app/asset/ui-icons/env-var-collection.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/file-code-02.svg b/frontend/app/asset/ui-icons/file-code-02.svg new file mode 100644 index 000000000..680206452 --- /dev/null +++ b/frontend/app/asset/ui-icons/file-code-02.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/file.svg b/frontend/app/asset/ui-icons/file.svg new file mode 100644 index 000000000..359fc3084 --- /dev/null +++ b/frontend/app/asset/ui-icons/file.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/filter-funnel-filled.svg b/frontend/app/asset/ui-icons/filter-funnel-filled.svg new file mode 100644 index 000000000..7ebbbf4f1 --- /dev/null +++ b/frontend/app/asset/ui-icons/filter-funnel-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/filter-funnel.svg b/frontend/app/asset/ui-icons/filter-funnel.svg new file mode 100644 index 000000000..766ac5904 --- /dev/null +++ b/frontend/app/asset/ui-icons/filter-funnel.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/filter-lines.svg b/frontend/app/asset/ui-icons/filter-lines.svg new file mode 100644 index 000000000..ad8a64713 --- /dev/null +++ b/frontend/app/asset/ui-icons/filter-lines.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/gear.svg b/frontend/app/asset/ui-icons/gear.svg new file mode 100644 index 000000000..0efb89a1b --- /dev/null +++ b/frontend/app/asset/ui-icons/gear.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/git-branch-02.svg b/frontend/app/asset/ui-icons/git-branch-02.svg new file mode 100644 index 000000000..8f085a82c --- /dev/null +++ b/frontend/app/asset/ui-icons/git-branch-02.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/grid.svg b/frontend/app/asset/ui-icons/grid.svg new file mode 100644 index 000000000..3199a0645 --- /dev/null +++ b/frontend/app/asset/ui-icons/grid.svg @@ -0,0 +1 @@ + diff --git a/frontend/app/asset/ui-icons/left-panel-close.svg b/frontend/app/asset/ui-icons/left-panel-close.svg new file mode 100644 index 000000000..c786f095f --- /dev/null +++ b/frontend/app/asset/ui-icons/left-panel-close.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/left-panel-open.svg b/frontend/app/asset/ui-icons/left-panel-open.svg new file mode 100644 index 000000000..f53a727f4 --- /dev/null +++ b/frontend/app/asset/ui-icons/left-panel-open.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/lightning-02.svg b/frontend/app/asset/ui-icons/lightning-02.svg new file mode 100644 index 000000000..733e9a19e --- /dev/null +++ b/frontend/app/asset/ui-icons/lightning-02.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/maximize-01.svg b/frontend/app/asset/ui-icons/maximize-01.svg new file mode 100644 index 000000000..27e1eb7a3 --- /dev/null +++ b/frontend/app/asset/ui-icons/maximize-01.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/app/asset/ui-icons/menu-01.svg b/frontend/app/asset/ui-icons/menu-01.svg new file mode 100644 index 000000000..1683e0506 --- /dev/null +++ b/frontend/app/asset/ui-icons/menu-01.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/minimize-01.svg b/frontend/app/asset/ui-icons/minimize-01.svg new file mode 100644 index 000000000..6c4df35d9 --- /dev/null +++ b/frontend/app/asset/ui-icons/minimize-01.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/new-conversation.svg b/frontend/app/asset/ui-icons/new-conversation.svg new file mode 100644 index 000000000..eb89dcfa3 --- /dev/null +++ b/frontend/app/asset/ui-icons/new-conversation.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/no_color_ellipse.svg b/frontend/app/asset/ui-icons/no_color_ellipse.svg new file mode 100644 index 000000000..d1580df7a --- /dev/null +++ b/frontend/app/asset/ui-icons/no_color_ellipse.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/notebook.svg b/frontend/app/asset/ui-icons/notebook.svg new file mode 100644 index 000000000..0219ed815 --- /dev/null +++ b/frontend/app/asset/ui-icons/notebook.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/paperclip.svg b/frontend/app/asset/ui-icons/paperclip.svg new file mode 100644 index 000000000..09223c2c4 --- /dev/null +++ b/frontend/app/asset/ui-icons/paperclip.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/plus.svg b/frontend/app/asset/ui-icons/plus.svg new file mode 100644 index 000000000..b5032c2da --- /dev/null +++ b/frontend/app/asset/ui-icons/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/prompt.svg b/frontend/app/asset/ui-icons/prompt.svg new file mode 100644 index 000000000..081130025 --- /dev/null +++ b/frontend/app/asset/ui-icons/prompt.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/refresh-ccw-01.svg b/frontend/app/asset/ui-icons/refresh-ccw-01.svg new file mode 100644 index 000000000..9ac22d21c --- /dev/null +++ b/frontend/app/asset/ui-icons/refresh-ccw-01.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/refresh-cw-04.svg b/frontend/app/asset/ui-icons/refresh-cw-04.svg new file mode 100644 index 000000000..cf20cc38e --- /dev/null +++ b/frontend/app/asset/ui-icons/refresh-cw-04.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/refresh.svg b/frontend/app/asset/ui-icons/refresh.svg new file mode 100644 index 000000000..fbff2e292 --- /dev/null +++ b/frontend/app/asset/ui-icons/refresh.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/reverse-left.svg b/frontend/app/asset/ui-icons/reverse-left.svg new file mode 100644 index 000000000..208aec3d1 --- /dev/null +++ b/frontend/app/asset/ui-icons/reverse-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/search-small.svg b/frontend/app/asset/ui-icons/search-small.svg new file mode 100644 index 000000000..c9ad3d455 --- /dev/null +++ b/frontend/app/asset/ui-icons/search-small.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/search.svg b/frontend/app/asset/ui-icons/search.svg new file mode 100644 index 000000000..855c489a7 --- /dev/null +++ b/frontend/app/asset/ui-icons/search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/app/asset/ui-icons/settings.svg b/frontend/app/asset/ui-icons/settings.svg new file mode 100644 index 000000000..2e067466b --- /dev/null +++ b/frontend/app/asset/ui-icons/settings.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/share-01.svg b/frontend/app/asset/ui-icons/share-01.svg new file mode 100644 index 000000000..d262652cd --- /dev/null +++ b/frontend/app/asset/ui-icons/share-01.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/share-03.svg b/frontend/app/asset/ui-icons/share-03.svg new file mode 100644 index 000000000..23c5c42fe --- /dev/null +++ b/frontend/app/asset/ui-icons/share-03.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/slash-circle.svg b/frontend/app/asset/ui-icons/slash-circle.svg new file mode 100644 index 000000000..24c337348 --- /dev/null +++ b/frontend/app/asset/ui-icons/slash-circle.svg @@ -0,0 +1 @@ + diff --git a/frontend/app/asset/ui-icons/sparkle.svg b/frontend/app/asset/ui-icons/sparkle.svg new file mode 100644 index 000000000..20705c112 --- /dev/null +++ b/frontend/app/asset/ui-icons/sparkle.svg @@ -0,0 +1 @@ + diff --git a/frontend/app/asset/ui-icons/stars-01.svg b/frontend/app/asset/ui-icons/stars-01.svg new file mode 100644 index 000000000..63ac5fef5 --- /dev/null +++ b/frontend/app/asset/ui-icons/stars-01.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/terminal-input.svg b/frontend/app/asset/ui-icons/terminal-input.svg new file mode 100644 index 000000000..39a6a707a --- /dev/null +++ b/frontend/app/asset/ui-icons/terminal-input.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/app/asset/ui-icons/terminal.svg b/frontend/app/asset/ui-icons/terminal.svg new file mode 100644 index 000000000..527dff87e --- /dev/null +++ b/frontend/app/asset/ui-icons/terminal.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/ui-icons/workflow.svg b/frontend/app/asset/ui-icons/workflow.svg new file mode 100644 index 000000000..a76358e50 --- /dev/null +++ b/frontend/app/asset/ui-icons/workflow.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/app/asset/ui-icons/x-circle.svg b/frontend/app/asset/ui-icons/x-circle.svg new file mode 100644 index 000000000..11304e5fa --- /dev/null +++ b/frontend/app/asset/ui-icons/x-circle.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/app/asset/ui-icons/x-close.svg b/frontend/app/asset/ui-icons/x-close.svg new file mode 100644 index 000000000..a16563310 --- /dev/null +++ b/frontend/app/asset/ui-icons/x-close.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/asset/workspace.svg b/frontend/app/asset/workspace.svg new file mode 100644 index 000000000..220153c89 --- /dev/null +++ b/frontend/app/asset/workspace.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/frontend/app/block/block-model.ts b/frontend/app/block/block-model.ts new file mode 100644 index 000000000..e2ce23e37 --- /dev/null +++ b/frontend/app/block/block-model.ts @@ -0,0 +1,51 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { globalStore } from "@/app/store/jotaiStore"; +import * as jotai from "jotai"; + +export interface BlockHighlightType { + blockId: string; + icon: string; +} + +export class BlockModel { + private static instance: BlockModel | null = null; + private blockHighlightAtomCache = new Map>(); + + blockHighlightAtom: jotai.PrimitiveAtom = jotai.atom(null) as jotai.PrimitiveAtom; + + private constructor() { + // Empty for now + } + + getBlockHighlightAtom(blockId: string): jotai.Atom { + let atom = this.blockHighlightAtomCache.get(blockId); + if (!atom) { + atom = jotai.atom((get) => { + const highlight = get(this.blockHighlightAtom); + if (highlight?.blockId === blockId) { + return highlight; + } + return null; + }); + this.blockHighlightAtomCache.set(blockId, atom); + } + return atom; + } + + setBlockHighlight(highlight: BlockHighlightType | null) { + globalStore.set(this.blockHighlightAtom, highlight); + } + + static getInstance(): BlockModel { + if (!BlockModel.instance) { + BlockModel.instance = new BlockModel(); + } + return BlockModel.instance; + } + + static resetInstance(): void { + BlockModel.instance = null; + } +} \ No newline at end of file diff --git a/frontend/app/block/block.scss b/frontend/app/block/block.scss new file mode 100644 index 000000000..6b0fda769 --- /dev/null +++ b/frontend/app/block/block.scss @@ -0,0 +1,448 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.block { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + overflow: hidden; + min-height: 0; + border-radius: var(--block-border-radius); + + .block-frame-icon { + margin-right: 0.5em; + } + + .block-content { + position: relative; + display: flex; + flex-grow: 1; + width: 100%; + overflow: hidden; + min-height: 0; + padding: 5px; + + &.block-no-padding { + padding: 0; + } + } + + .block-focuselem { + height: 0; + width: 0; + input { + width: 0; + height: 0; + opacity: 0; + pointer-events: none; + } + } + + .block-header-animation-wrap { + max-height: 0; + transition: + max-height 0.3s ease-out, + opacity 0.3s ease-out; + overflow: hidden; + position: absolute; + top: 0; + width: 100%; + height: 30px; + z-index: var(--zindex-header-hover); + + &.is-showing { + max-height: 30px; + } + } + + &.block-preview.block-frame-default .block-frame-default-inner .block-frame-default-header { + background-color: rgb(from var(--block-bg-color) r g b / 70%); + } + + &.block-frame-default { + position: relative; + padding: 1px; + + .block-frame-default-inner { + background-color: var(--block-bg-color); + width: 100%; + height: 100%; + border-radius: var(--block-border-radius); + display: flex; + flex-direction: column; + + .block-frame-default-header { + max-height: var(--header-height); + min-height: var(--header-height); + display: flex; + padding: 4px 5px 4px 7px; + align-items: center; + gap: 8px; + font: var(--header-font); + border-bottom: 1px solid var(--border-color); + border-radius: var(--block-border-radius) var(--block-border-radius) 0 0; + + .block-frame-default-header-iconview { + display: flex; + flex-shrink: 3; + min-width: 17px; + align-items: center; + gap: 8px; + overflow-x: hidden; + + .block-frame-view-icon { + font-size: var(--header-icon-size); + opacity: 0.5; + width: var(--header-icon-width); + i { + margin-right: 0; + } + } + + .block-frame-view-type { + overflow-x: hidden; + text-wrap: nowrap; + text-overflow: ellipsis; + flex-shrink: 1; + min-width: 0; + } + + .block-frame-blockid { + opacity: 0.5; + } + } + + .block-frame-text { + font: var(--fixed-font); + font-size: 11px; + opacity: 0.7; + flex-grow: 1; + + &.flex-nogrow { + flex-grow: 0; + } + + &.preview-filename { + direction: rtl; + text-align: left; + span { + cursor: pointer; + + &:hover { + background: var(--highlight-bg-color); + } + } + } + } + + .connection-button { + display: flex; + align-items: center; + flex-wrap: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + font-weight: 400; + color: var(--main-text-color); + border-radius: 2px; + padding: auto; + + &:hover { + background-color: var(--highlight-bg-color); + } + + .connection-icon-box { + flex: 1 1 auto; + overflow: hidden; + } + + .connection-name { + flex: 1 2 auto; + overflow: hidden; + padding-right: 4px; + } + + .connecting-svg { + position: relative; + top: 5px; + left: 9px; + svg { + fill: var(--warning-color); + } + } + } + + .block-frame-textelems-wrapper { + display: flex; + flex: 1 2 auto; + min-width: 0; + gap: 8px; + align-items: center; + + .block-frame-div { + display: flex; + width: 100%; + height: 100%; + justify-content: space-between; + align-items: center; + + .input-wrapper { + flex-grow: 1; + + input { + background-color: transparent; + outline: none; + border: none; + color: var(--main-text-color); + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + box-sizing: border-box; + opacity: 0.7; + font-weight: 500; + } + } + + .wave-button { + margin-left: 3px; + } + + // webview specific. for refresh button + .wave-iconbutton { + height: 100%; + width: 27px; + display: flex; + align-items: center; + justify-content: center; + } + } + + .menubutton { + .wave-button { + font-size: 11px; + } + } + } + + .block-frame-end-icons { + display: flex; + flex-shrink: 0; + + .wave-iconbutton { + display: flex; + width: 24px; + padding: 4px 6px; + align-items: center; + } + + .block-frame-magnify { + justify-content: center; + align-items: center; + padding: 0; + + svg { + #arrow1, + #arrow2 { + fill: var(--main-text-color); + } + } + } + } + } + + .block-frame-preview { + background-color: rgb(from var(--block-bg-color) r g b / 70%); + width: 100%; + flex-grow: 1; + border-bottom-left-radius: var(--block-border-radius); + border-bottom-right-radius: var(--block-border-radius); + display: flex; + align-items: center; + justify-content: center; + + .wave-iconbutton { + opacity: 0.7; + font-size: 45px; + margin: -30px 0 0 0; + } + } + } + + --magnified-block-opacity: 0.6; + --magnified-block-blur: 10px; + + &.magnified, + &.ephemeral { + background-color: rgb(from var(--block-bg-color) r g b / var(--magnified-block-opacity)); + backdrop-filter: blur(var(--magnified-block-blur)); + } + + .connstatus-overlay { + position: absolute; + top: calc(var(--header-height) + 6px); + left: 8px; + right: 8px; + z-index: var(--zindex-block-mask-inner); + display: flex; + align-items: center; + justify-content: flex-start; + flex-direction: column; + overflow: hidden; + background: var(--conn-status-overlay-bg-color); + backdrop-filter: blur(50px); + border-radius: 6px; + box-shadow: 0px 13px 16px 0px rgb(from var(--block-bg-color) r g b / 40%); + opacity: 0.9; + + .connstatus-content { + display: flex; + flex-direction: row; + justify-content: space-between; + padding: 10px 8px 10px 12px; + width: 100%; + font: var(--base-font); + color: var(--secondary-text-color); + + .connstatus-status-icon-wrapper { + display: flex; + flex-direction: row; + align-items: center; + gap: 12px; + flex-grow: 1; + min-width: 0; + + &.has-error { + align-items: flex-start; + } + + > i { + color: #e6ba1e; + font-size: 16px; + } + + .connstatus-status { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + flex-grow: 1; + width: 100%; + + .connstatus-status-text { + max-width: 100%; + font-size: 11px; + font-style: normal; + font-weight: 600; + line-height: 16px; + letter-spacing: 0.11px; + color: white; + } + + .connstatus-error { + font-size: 11px; + font-style: normal; + font-weight: 400; + line-height: 15px; + letter-spacing: 0.11px; + text-wrap: wrap; + max-height: 80px; + border-radius: 8px; + padding: 5px; + padding-left: 0; + position: relative; + + .copy-button { + visibility: hidden; + display: flex; + position: sticky; + top: 0; + right: 4px; + float: right; + border-radius: 4px; + backdrop-filter: blur(8px); + padding: 0.286em; + align-items: center; + justify-content: flex-end; + gap: 0.286em; + } + + &:hover .copy-button { + visibility: visible; + } + } + } + } + + .connstatus-actions { + display: flex; + align-items: flex-start; + justify-content: center; + gap: 6px; + + button { + i { + font-size: 11px; + opacity: 0.7; + } + } + + .wave-button:last-child { + margin-top: 1.5px; + } + } + } + } + + .block-mask { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border: 2px solid transparent; + pointer-events: none; + padding: 2px; + border-radius: var(--block-border-radius); + z-index: var(--zindex-block-mask-inner); + + &.show-block-mask { + user-select: none; + pointer-events: auto; + } + + &.show-block-mask .block-mask-inner { + margin-top: var(--header-height); // TODO fix this magic + background-color: rgb(from var(--block-bg-color) r g b / 50%); + height: calc(100% - var(--header-height)); + width: 100%; + display: flex; + align-items: center; + justify-content: center; + + .bignum { + margin-top: -15%; + font-size: 60px; + font-weight: bold; + opacity: 0.7; + } + } + } + + &.block-focused { + .block-mask { + border: 2px solid var(--accent-color); + } + + &.block-no-highlight, + &.block-preview { + .block-mask { + border: 2px solid rgb(from var(--border-color) r g b / 10%) !important; + } + } + } + } +} diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx new file mode 100644 index 000000000..acc11aa73 --- /dev/null +++ b/frontend/app/block/block.tsx @@ -0,0 +1,342 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { + BlockComponentModel2, + BlockProps, + FullBlockProps, + FullSubBlockProps, + SubBlockProps, +} from "@/app/block/blocktypes"; +import { useTabModel } from "@/app/store/tab-model"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; +import { ErrorBoundary } from "@/element/errorboundary"; +import { CenteredDiv } from "@/element/quickelems"; +import { useDebouncedNodeInnerRect } from "@/layout/index"; +import { counterInc } from "@/store/counters"; +import { globalStore } from "@/app/store/jotaiStore"; +import { getBlockComponentModel, registerBlockComponentModel, unregisterBlockComponentModel } from "@/store/global"; +import { makeORef } from "@/store/wos"; +import { focusedBlockId, getElemAsStr } from "@/util/focusutil"; +import { isBlank, useAtomValueSafe } from "@/util/util"; +import clsx from "clsx"; +import { useAtomValue } from "jotai"; +import { memo, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import "./block.scss"; +import { BlockEnv } from "./blockenv"; +import { BlockFrame } from "./blockframe"; +import { makeViewModel } from "./blockregistry"; + +function getViewElem( + blockId: string, + blockRef: React.RefObject, + contentRef: React.RefObject, + blockView: string, + viewModel: ViewModel +): React.ReactElement { + if (isBlank(blockView)) { + return No View; + } + if (viewModel.viewComponent == null) { + return No View Component; + } + const VC = viewModel.viewComponent; + return ; +} + +const BlockPreview = memo(({ nodeModel, viewModel }: FullBlockProps) => { + const waveEnv = useWaveEnv(); + const blockIsNull = useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef("block", nodeModel.blockId))); + if (blockIsNull) { + return null; + } + return ( + + ); +}); + +const BlockSubBlock = memo(({ nodeModel, viewModel }: FullSubBlockProps) => { + const waveEnv = useWaveEnv(); + const blockIsNull = useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef("block", nodeModel.blockId))); + const blockView = useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "view")) ?? ""; + const blockRef = useRef(null); + const contentRef = useRef(null); + const viewElem = useMemo( + () => getViewElem(nodeModel.blockId, blockRef, contentRef, blockView, viewModel), + [nodeModel.blockId, blockView, viewModel] + ); + const noPadding = useAtomValueSafe(viewModel.noPadding); + if (blockIsNull) { + return null; + } + return ( +
+ + Loading...}>{viewElem} + +
+ ); +}); + +const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => { + counterInc("render-BlockFull"); + const waveEnv = useWaveEnv(); + const focusElemRef = useRef(null); + const blockRef = useRef(null); + const contentRef = useRef(null); + const pendingFocusRafRef = useRef(null); + const [blockClicked, setBlockClicked] = useState(false); + const blockView = useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "view")) ?? ""; + const isFocused = useAtomValue(nodeModel.isFocused); + const disablePointerEvents = useAtomValue(nodeModel.disablePointerEvents); + const isResizing = useAtomValue(nodeModel.isResizing); + const isMagnified = useAtomValue(nodeModel.isMagnified); + const anyMagnified = useAtomValue(nodeModel.anyMagnified); + const modalOpen = useAtomValue(waveEnv.atoms.modalOpen); + const focusFollowsCursorMode = useAtomValue(waveEnv.getSettingsKeyAtom("app:focusfollowscursor")) ?? "off"; + const innerRect = useDebouncedNodeInnerRect(nodeModel); + const noPadding = useAtomValueSafe(viewModel.noPadding); + + useEffect(() => { + return () => { + if (pendingFocusRafRef.current != null) { + cancelAnimationFrame(pendingFocusRafRef.current); + } + }; + }, []); + + useLayoutEffect(() => { + if (!isFocused) { + return; + } + const focusWithin = focusedBlockId() == nodeModel.blockId; + if (!focusWithin) { + setFocusTarget(); + } + }, [isFocused, nodeModel]); + + useLayoutEffect(() => { + if (!blockClicked) { + return; + } + setBlockClicked(false); + const focusWithin = focusedBlockId() == nodeModel.blockId; + if (!focusWithin) { + setFocusTarget(); + } + if (!globalStore.get(nodeModel.isFocused)) { + nodeModel.focusNode(); + } + }, [blockClicked, nodeModel]); + + const setBlockClickedTrue = useCallback(() => { + setBlockClicked(true); + }, []); + + const [blockContentOffset, setBlockContentOffset] = useState(); + + useEffect(() => { + if (blockRef.current && contentRef.current) { + const blockRect = blockRef.current.getBoundingClientRect(); + const contentRect = contentRef.current.getBoundingClientRect(); + setBlockContentOffset({ + top: 0, + left: 0, + width: blockRect.width - contentRect.width, + height: blockRect.height - contentRect.height, + }); + } + }, [blockRef, contentRef]); + + const blockContentStyle = useMemo(() => { + const retVal: React.CSSProperties = { + pointerEvents: disablePointerEvents ? "none" : undefined, + }; + if (innerRect?.width && innerRect.height && blockContentOffset) { + retVal.width = `calc(${innerRect?.width} - ${blockContentOffset.width}px)`; + retVal.height = `calc(${innerRect?.height} - ${blockContentOffset.height}px)`; + } + return retVal; + }, [innerRect, disablePointerEvents, blockContentOffset]); + + const viewElem = useMemo( + () => getViewElem(nodeModel.blockId, blockRef, contentRef, blockView, viewModel), + [nodeModel.blockId, blockView, viewModel] + ); + + const handleChildFocus = useCallback( + (event: React.FocusEvent) => { + if (globalStore.get(nodeModel.isFocused)) { + return; + } + nodeModel.focusNode(); + }, + [nodeModel] + ); + + const setFocusTarget = useCallback(() => { + if (pendingFocusRafRef.current != null) { + cancelAnimationFrame(pendingFocusRafRef.current); + pendingFocusRafRef.current = null; + } + const ok = viewModel?.giveFocus?.(); + if (ok) { + return; + } + focusElemRef.current?.focus({ preventScroll: true }); + pendingFocusRafRef.current = requestAnimationFrame(() => { + pendingFocusRafRef.current = null; + if (blockRef.current?.contains(document.activeElement)) { + viewModel?.giveFocus?.(); + } + }); + }, [viewModel, nodeModel]); + + const focusFromPointerEnter = useCallback( + (event: React.PointerEvent) => { + const focusFollowsCursorEnabled = + focusFollowsCursorMode === "on" || (focusFollowsCursorMode === "term" && blockView === "term"); + if (!focusFollowsCursorEnabled || event.pointerType === "touch" || event.buttons > 0) { + return; + } + if (modalOpen || disablePointerEvents || isResizing || (anyMagnified && !isMagnified)) { + return; + } + if (isFocused && focusedBlockId() === nodeModel.blockId) { + return; + } + setFocusTarget(); + if (!isFocused) { + nodeModel.focusNode(); + } + }, + [ + focusFollowsCursorMode, + blockView, + modalOpen, + disablePointerEvents, + isResizing, + isMagnified, + anyMagnified, + isFocused, + nodeModel, + setFocusTarget, + ] + ); + + const blockModel = useMemo( + () => ({ + onClick: setBlockClickedTrue, + onPointerEnter: focusFromPointerEnter, + onFocusCapture: handleChildFocus, + blockRef: blockRef, + }), + [setBlockClickedTrue, focusFromPointerEnter, handleChildFocus, blockRef] + ); + + return ( + +
+ {}} + /> +
+
+ + Loading...}>{viewElem} + +
+
+ ); +}); + +const BlockInner = memo((props: BlockProps & { viewType: string }) => { + counterInc("render-Block"); + counterInc("render-Block-" + props.nodeModel?.blockId?.substring(0, 8)); + const tabModel = useTabModel(); + const waveEnv = useWaveEnv(); + const bcm = getBlockComponentModel(props.nodeModel.blockId); + let viewModel = bcm?.viewModel; + if (viewModel == null) { + // viewModel gets the full waveEnv + viewModel = makeViewModel(props.nodeModel.blockId, props.viewType, props.nodeModel, tabModel, waveEnv); + registerBlockComponentModel(props.nodeModel.blockId, { viewModel }); + } + useEffect(() => { + return () => { + unregisterBlockComponentModel(props.nodeModel.blockId); + viewModel?.dispose?.(); + }; + }, []); + if (props.preview) { + return ; + } + return ; +}); +BlockInner.displayName = "BlockInner"; + +const Block = memo((props: BlockProps) => { + const waveEnv = useWaveEnv(); + const isNull = useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef("block", props.nodeModel.blockId))); + const viewType = useAtomValue(waveEnv.getBlockMetaKeyAtom(props.nodeModel.blockId, "view")) ?? ""; + if (isNull || isBlank(props.nodeModel.blockId)) { + return null; + } + return ; +}); + +const SubBlockInner = memo((props: SubBlockProps & { viewType: string }) => { + counterInc("render-Block"); + counterInc("render-Block-" + props.nodeModel.blockId?.substring(0, 8)); + const tabModel = useTabModel(); + const waveEnv = useWaveEnv(); + const bcm = getBlockComponentModel(props.nodeModel.blockId); + let viewModel = bcm?.viewModel; + if (viewModel == null) { + // viewModel gets the full waveEnv + viewModel = makeViewModel(props.nodeModel.blockId, props.viewType, props.nodeModel, tabModel, waveEnv); + registerBlockComponentModel(props.nodeModel.blockId, { viewModel }); + } + useEffect(() => { + return () => { + unregisterBlockComponentModel(props.nodeModel.blockId); + viewModel?.dispose?.(); + }; + }, []); + return ; +}); +SubBlockInner.displayName = "SubBlockInner"; + +const SubBlock = memo((props: SubBlockProps) => { + const waveEnv = useWaveEnv(); + const isNull = useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef("block", props.nodeModel.blockId))); + const viewType = useAtomValue(waveEnv.getBlockMetaKeyAtom(props.nodeModel.blockId, "view")) ?? ""; + if (isNull || isBlank(props.nodeModel.blockId)) { + return null; + } + return ; +}); + +export { Block, SubBlock }; diff --git a/frontend/app/block/blockenv.ts b/frontend/app/block/blockenv.ts new file mode 100644 index 000000000..8a529be11 --- /dev/null +++ b/frontend/app/block/blockenv.ts @@ -0,0 +1,52 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { + ConnConfigKeyAtomFnType, + MetaKeyAtomFnType, + SettingsKeyAtomFnType, + WaveEnv, + WaveEnvSubset, +} from "@/app/waveenv/waveenv"; + +export type BlockEnv = WaveEnvSubset<{ + getSettingsKeyAtom: SettingsKeyAtomFnType< + | "app:focusfollowscursor" + | "app:showoverlayblocknums" + | "term:showsplitbuttons" + | "window:magnifiedblockblurprimarypx" + | "window:magnifiedblockopacity" + >; + showContextMenu: WaveEnv["showContextMenu"]; + atoms: { + modalOpen: WaveEnv["atoms"]["modalOpen"]; + controlShiftDelayAtom: WaveEnv["atoms"]["controlShiftDelayAtom"]; + }; + electron: { + openExternal: WaveEnv["electron"]["openExternal"]; + }; + rpc: { + ActivityCommand: WaveEnv["rpc"]["ActivityCommand"]; + ConnEnsureCommand: WaveEnv["rpc"]["ConnEnsureCommand"]; + ConnDisconnectCommand: WaveEnv["rpc"]["ConnDisconnectCommand"]; + ConnConnectCommand: WaveEnv["rpc"]["ConnConnectCommand"]; + SetConnectionsConfigCommand: WaveEnv["rpc"]["SetConnectionsConfigCommand"]; + DismissWshFailCommand: WaveEnv["rpc"]["DismissWshFailCommand"]; + }; + wos: WaveEnv["wos"]; + getConnStatusAtom: WaveEnv["getConnStatusAtom"]; + getLocalHostDisplayNameAtom: WaveEnv["getLocalHostDisplayNameAtom"]; + getConnConfigKeyAtom: ConnConfigKeyAtomFnType<"conn:wshenabled">; + getBlockMetaKeyAtom: MetaKeyAtomFnType< + | "frame:text" + | "frame:activebordercolor" + | "frame:bordercolor" + | "view" + | "connection" + | "icon:color" + | "frame:title" + | "frame:icon" + >; + getTabMetaKeyAtom: MetaKeyAtomFnType<"bg:activebordercolor" | "bg:bordercolor" | "tab:background">; + getConfigBackgroundAtom: WaveEnv["getConfigBackgroundAtom"]; +}>; diff --git a/frontend/app/block/blockframe-header.tsx b/frontend/app/block/blockframe-header.tsx new file mode 100644 index 000000000..806e9850e --- /dev/null +++ b/frontend/app/block/blockframe-header.tsx @@ -0,0 +1,289 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { + blockViewToIcon, + blockViewToName, + getViewIconElem, + OptMagnifyButton, + renderHeaderElements, +} from "@/app/block/blockutil"; +import { ConnectionButton } from "@/app/block/connectionbutton"; +import { DurableSessionFlyover } from "@/app/block/durable-session-flyover"; +import { getBlockBadgeAtom } from "@/app/store/badge"; +import { + createBlockSplitHorizontally, + createBlockSplitVertically, + recordTEvent, + refocusNode, + WOS, +} from "@/app/store/global"; +import { globalStore } from "@/app/store/jotaiStore"; +import { uxCloseBlock } from "@/app/store/keymodel"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; +import { IconButton } from "@/element/iconbutton"; +import { NodeModel } from "@/layout/index"; +import * as util from "@/util/util"; +import { cn, makeIconClass } from "@/util/util"; +import * as jotai from "jotai"; +import * as React from "react"; +import { BlockEnv } from "./blockenv"; +import { BlockFrameProps } from "./blocktypes"; + +function handleHeaderContextMenu( + e: React.MouseEvent, + blockId: string, + viewModel: ViewModel, + nodeModel: NodeModel, + blockEnv: BlockEnv +) { + e.preventDefault(); + e.stopPropagation(); + const magnified = globalStore.get(nodeModel.isMagnified); + const menu: ContextMenuItem[] = [ + { + label: magnified ? "Un-Magnify Block" : "Magnify Block", + click: () => { + nodeModel.toggleMagnify(); + }, + }, + ]; + const extraItems = viewModel?.getSettingsMenuItems?.(); + if (extraItems && extraItems.length > 0) menu.push({ type: "separator" }, ...extraItems); + menu.push( + { type: "separator" }, + { + label: "Close Block", + click: () => uxCloseBlock(blockId), + } + ); + blockEnv.showContextMenu(menu, e); +} + +type HeaderTextElemsProps = { + viewModel: ViewModel; + blockId: string; + preview: boolean; + error?: Error; +}; + +const HeaderTextElems = React.memo(({ viewModel, blockId, preview, error }: HeaderTextElemsProps) => { + const waveEnv = useWaveEnv(); + const frameTextAtom = waveEnv.getBlockMetaKeyAtom(blockId, "frame:text"); + const frameText = jotai.useAtomValue(frameTextAtom); + let headerTextUnion = util.useAtomValueSafe(viewModel?.viewText); + headerTextUnion = frameText ?? headerTextUnion; + + const headerTextElems: React.ReactElement[] = []; + if (typeof headerTextUnion === "string") { + if (!util.isBlank(headerTextUnion)) { + headerTextElems.push( +
+ ‎{headerTextUnion} +
+ ); + } + } else if (Array.isArray(headerTextUnion)) { + headerTextElems.push(...renderHeaderElements(headerTextUnion, preview)); + } + if (error != null) { + const copyHeaderErr = () => { + navigator.clipboard.writeText(error.message + "\n" + error.stack); + }; + headerTextElems.push( +
+ +
+ ); + } + + return
{headerTextElems}
; +}); +HeaderTextElems.displayName = "HeaderTextElems"; + +type HeaderEndIconsProps = { + viewModel: ViewModel; + nodeModel: NodeModel; + blockId: string; +}; + +const HeaderEndIcons = React.memo(({ viewModel, nodeModel, blockId }: HeaderEndIconsProps) => { + const blockEnv = useWaveEnv(); + const endIconButtons = util.useAtomValueSafe(viewModel?.endIconButtons); + const magnified = jotai.useAtomValue(nodeModel.isMagnified); + const ephemeral = jotai.useAtomValue(nodeModel.isEphemeral); + const numLeafs = jotai.useAtomValue(nodeModel.numLeafs); + const magnifyDisabled = numLeafs <= 1; + const showSplitButtons = jotai.useAtomValue(blockEnv.getSettingsKeyAtom("term:showsplitbuttons")); + + const endIconsElem: React.ReactElement[] = []; + + if (endIconButtons && endIconButtons.length > 0) { + endIconsElem.push(...endIconButtons.map((button, idx) => )); + } + if (showSplitButtons && viewModel?.viewType === "term") { + const splitHorizontalDecl: IconButtonDecl = { + elemtype: "iconbutton", + icon: "columns", + title: "Split Horizontally", + click: (e) => { + e.stopPropagation(); + const blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", blockId)); + const blockData = globalStore.get(blockAtom); + const blockDef: BlockDef = { + meta: blockData?.meta || { view: "term", controller: "shell" }, + }; + createBlockSplitHorizontally(blockDef, blockId, "after"); + }, + }; + const splitVerticalDecl: IconButtonDecl = { + elemtype: "iconbutton", + icon: "grip-lines", + title: "Split Vertically", + click: (e) => { + e.stopPropagation(); + const blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", blockId)); + const blockData = globalStore.get(blockAtom); + const blockDef: BlockDef = { + meta: blockData?.meta || { view: "term", controller: "shell" }, + }; + createBlockSplitVertically(blockDef, blockId, "after"); + }, + }; + endIconsElem.push(); + endIconsElem.push(); + } + const settingsDecl: IconButtonDecl = { + elemtype: "iconbutton", + icon: "cog", + title: "Settings", + click: (e) => handleHeaderContextMenu(e, blockId, viewModel, nodeModel, blockEnv), + }; + endIconsElem.push(); + if (ephemeral) { + const addToLayoutDecl: IconButtonDecl = { + elemtype: "iconbutton", + icon: "circle-plus", + title: "Add to Layout", + click: () => { + nodeModel.addEphemeralNodeToLayout(); + }, + }; + endIconsElem.push(); + } else { + endIconsElem.push( + { + nodeModel.toggleMagnify(); + setTimeout(() => refocusNode(blockId), 50); + }} + disabled={magnifyDisabled} + /> + ); + } + + const closeDecl: IconButtonDecl = { + elemtype: "iconbutton", + icon: "xmark-large", + title: "Close", + click: () => uxCloseBlock(nodeModel.blockId), + }; + endIconsElem.push(); + + return
{endIconsElem}
; +}); +HeaderEndIcons.displayName = "HeaderEndIcons"; + +const BlockFrame_Header = ({ + nodeModel, + viewModel, + preview, + connBtnRef, + changeConnModalAtom, + error, +}: BlockFrameProps & { changeConnModalAtom: jotai.PrimitiveAtom; error?: Error }) => { + const waveEnv = useWaveEnv(); + const metaView = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "view")); + const metaFrameTitle = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "frame:title")); + const metaFrameIcon = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "frame:icon")); + const metaConnection = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "connection")); + let viewName = util.useAtomValueSafe(viewModel?.viewName) ?? blockViewToName(metaView); + let viewIconUnion = util.useAtomValueSafe(viewModel?.viewIcon) ?? blockViewToIcon(metaView); + const preIconButton = util.useAtomValueSafe(viewModel?.preIconButton); + const useTermHeader = util.useAtomValueSafe(viewModel?.useTermHeader); + const termConfigedDurable = util.useAtomValueSafe(viewModel?.termConfigedDurable); + const hideViewName = util.useAtomValueSafe(viewModel?.hideViewName); + const badge = jotai.useAtomValue(getBlockBadgeAtom(useTermHeader ? nodeModel.blockId : null)); + const magnified = jotai.useAtomValue(nodeModel.isMagnified); + const prevMagifiedState = React.useRef(magnified); + const manageConnection = util.useAtomValueSafe(viewModel?.manageConnection); + const iconColor = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "icon:color")); + const dragHandleRef = preview ? null : nodeModel.dragHandleRef; + const isTerminalBlock = metaView === "term"; + viewName = metaFrameTitle ?? viewName; + viewIconUnion = metaFrameIcon ?? viewIconUnion; + + React.useEffect(() => { + if (magnified && !preview && !prevMagifiedState.current) { + waveEnv.rpc.ActivityCommand(TabRpcClient, { nummagnify: 1 }); + recordTEvent("action:magnify", { "block:view": viewName }); + } + prevMagifiedState.current = magnified; + }, [magnified]); + + const viewIconElem = getViewIconElem(viewIconUnion, iconColor); + + return ( +
handleHeaderContextMenu(e, nodeModel.blockId, viewModel, nodeModel, waveEnv)} + > + {!useTermHeader && ( + <> + {preIconButton && } + {(viewIconUnion !== "" || (viewName && !hideViewName)) && ( +
+ {viewIconUnion !== "" && viewIconElem} + {viewName && !hideViewName &&
{viewName}
} +
+ )} + + )} + {manageConnection && ( + + )} + {useTermHeader && termConfigedDurable != null && ( + + )} + {useTermHeader && badge && ( +
+ +
+ )} + + +
+ ); +}; + +export { BlockFrame_Header }; diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx new file mode 100644 index 000000000..30bb5bb92 --- /dev/null +++ b/frontend/app/block/blockframe.tsx @@ -0,0 +1,230 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { BlockModel } from "@/app/block/block-model"; +import { BlockFrame_Header } from "@/app/block/blockframe-header"; +import { blockViewToIcon, getViewIconElem, useTabBackground } from "@/app/block/blockutil"; +import { ConnStatusOverlay } from "@/app/block/connstatusoverlay"; +import { ChangeConnectionBlockModal } from "@/app/modals/conntypeahead"; +import { getBlockComponentModel, globalStore, useBlockAtom } from "@/app/store/global"; +import { useTabModel } from "@/app/store/tab-model"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; +import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; +import { ErrorBoundary } from "@/element/errorboundary"; +import { NodeModel } from "@/layout/index"; +import { makeORef } from "@/store/wos"; +import * as util from "@/util/util"; +import { cn, makeIconClass } from "@/util/util"; +import { computeBgStyleFromMeta } from "@/util/waveutil"; +import clsx from "clsx"; +import * as jotai from "jotai"; +import * as React from "react"; +import { BlockEnv } from "./blockenv"; +import { BlockFrameProps } from "./blocktypes"; + +const BlockMask = React.memo(({ nodeModel }: { nodeModel: NodeModel }) => { + const waveEnv = useWaveEnv(); + const tabModel = useTabModel(); + const isFocused = jotai.useAtomValue(nodeModel.isFocused); + const isEphemeral = jotai.useAtomValue(nodeModel.isEphemeral); + const blockNum = jotai.useAtomValue(nodeModel.blockNum); + const isLayoutMode = jotai.useAtomValue(waveEnv.atoms.controlShiftDelayAtom); + const showOverlayBlockNums = jotai.useAtomValue(waveEnv.getSettingsKeyAtom("app:showoverlayblocknums")) ?? true; + const blockHighlight = jotai.useAtomValue(BlockModel.getInstance().getBlockHighlightAtom(nodeModel.blockId)); + const frameActiveBorderColor = jotai.useAtomValue( + waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "frame:activebordercolor") + ); + const frameBorderColor = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "frame:bordercolor")); + const [tabBorderColor, tabActiveBorderColor] = useTabBackground(waveEnv, tabModel.tabId); + const style: React.CSSProperties = {}; + let showBlockMask = false; + + if (isFocused) { + if (tabActiveBorderColor) { + style.borderColor = tabActiveBorderColor; + } + if (frameActiveBorderColor) { + style.borderColor = frameActiveBorderColor; + } + } else { + if (tabBorderColor) { + style.borderColor = tabBorderColor; + } + if (frameBorderColor) { + style.borderColor = frameBorderColor; + } + if (isEphemeral && !style.borderColor) { + style.borderColor = "rgba(255, 255, 255, 0.7)"; + } + } + + if (blockHighlight && !style.borderColor) { + style.borderColor = "rgb(59, 130, 246)"; + } + + let innerElem = null; + if (isLayoutMode && showOverlayBlockNums) { + showBlockMask = true; + innerElem = ( +
+
{blockNum}
+
+ ); + } else if (blockHighlight) { + showBlockMask = true; + const iconClass = makeIconClass(blockHighlight.icon, false); + innerElem = ( +
+ +
+ ); + } + + return ( +
+ {innerElem} +
+ ); +}); + +const BlockFrame_Default_Component = (props: BlockFrameProps) => { + const waveEnv = useWaveEnv(); + const { nodeModel, viewModel, blockModel, preview, numBlocksInTab, children } = props; + const isFocused = jotai.useAtomValue(nodeModel.isFocused); + const aiPanelVisible = jotai.useAtomValue(WorkspaceLayoutModel.getInstance().panelVisibleAtom); + const metaView = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "view")); + const viewIconUnion = util.useAtomValueSafe(viewModel?.viewIcon) ?? blockViewToIcon(metaView); + const customBg = util.useAtomValueSafe(viewModel?.blockBg); + const manageConnection = util.useAtomValueSafe(viewModel?.manageConnection); + const changeConnModalAtom = useBlockAtom(nodeModel.blockId, "changeConn", () => { + return jotai.atom(false); + }) as jotai.PrimitiveAtom; + const connModalOpen = jotai.useAtomValue(changeConnModalAtom); + const isMagnified = jotai.useAtomValue(nodeModel.isMagnified); + const isEphemeral = jotai.useAtomValue(nodeModel.isEphemeral); + const [magnifiedBlockBlurAtom] = React.useState(() => + waveEnv.getSettingsKeyAtom("window:magnifiedblockblurprimarypx") + ); + const magnifiedBlockBlur = jotai.useAtomValue(magnifiedBlockBlurAtom); + const [magnifiedBlockOpacityAtom] = React.useState(() => + waveEnv.getSettingsKeyAtom("window:magnifiedblockopacity") + ); + const magnifiedBlockOpacity = jotai.useAtomValue(magnifiedBlockOpacityAtom); + const connBtnRef = React.useRef(null); + const connName = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "connection")); + const iconColor = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "icon:color")); + const noHeader = util.useAtomValueSafe(viewModel?.noHeader); + + React.useEffect(() => { + if (!manageConnection) { + return; + } + const bcm = getBlockComponentModel(nodeModel.blockId); + if (bcm != null) { + bcm.openSwitchConnection = () => { + globalStore.set(changeConnModalAtom, true); + }; + } + return () => { + const bcm = getBlockComponentModel(nodeModel.blockId); + if (bcm != null) { + bcm.openSwitchConnection = null; + } + }; + }, [manageConnection]); + React.useEffect(() => { + // on mount, if manageConnection, call ConnEnsure + if (!manageConnection || preview) { + return; + } + if (!util.isLocalConnName(connName)) { + console.log("ensure conn", nodeModel.blockId, connName); + waveEnv.rpc + .ConnEnsureCommand( + TabRpcClient, + { connname: connName, logblockid: nodeModel.blockId }, + { timeout: 60000 } + ) + .catch((e) => { + console.log("error ensuring connection", nodeModel.blockId, connName, e); + }); + } + }, [manageConnection, connName]); + + const viewIconElem = getViewIconElem(viewIconUnion, iconColor); + let innerStyle: React.CSSProperties = {}; + if (!preview) { + innerStyle = computeBgStyleFromMeta(customBg); + } + const previewElem =
{viewIconElem}
; + const headerElem = ( + + ); + const headerElemNoView = React.cloneElement(headerElem, { viewModel: null }); + return ( +
+ + {preview || viewModel == null || !manageConnection ? null : ( + + )} +
+ {noHeader || {headerElem}} + {preview ? previewElem : children} +
+ {preview || viewModel == null || !connModalOpen ? null : ( + + )} +
+ ); +}; + +const BlockFrame_Default = React.memo(BlockFrame_Default_Component) as typeof BlockFrame_Default_Component; + +const BlockFrame = React.memo((props: BlockFrameProps) => { + const waveEnv = useWaveEnv(); + const tabModel = useTabModel(); + const blockId = props.nodeModel.blockId; + const blockIsNull = jotai.useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef("block", blockId))); + const numBlocks = jotai.useAtomValue(tabModel.tabNumBlocksAtom); + if (!blockId || blockIsNull) { + return null; + } + return ; +}); + +export { BlockFrame }; diff --git a/frontend/app/block/blockregistry.ts b/frontend/app/block/blockregistry.ts new file mode 100644 index 000000000..53a86075e --- /dev/null +++ b/frontend/app/block/blockregistry.ts @@ -0,0 +1,63 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { BlockNodeModel } from "@/app/block/blocktypes"; +import type { TabModel } from "@/app/store/tab-model"; +import { LauncherViewModel } from "@/app/view/launcher/launcher"; +import { PreviewModel } from "@/app/view/preview/preview-model"; +import { ProcessViewerViewModel } from "@/app/view/processviewer/processviewer"; +import { SysinfoViewModel } from "@/app/view/sysinfo/sysinfo"; +import { TermBlocksViewModel } from "@/app/view/termblocks/termblocks"; +import { TsunamiViewModel } from "@/app/view/tsunami/tsunami"; +import { VDomModel } from "@/app/view/vdom/vdom-model"; +import { WaveEnv } from "@/app/waveenv/waveenv"; +import { atom } from "jotai"; +import { QuickTipsViewModel } from "../view/quicktipsview/quicktipsview"; +import { WaveConfigViewModel } from "../view/waveconfig/waveconfig-model"; +import { blockViewToIcon, blockViewToName } from "./blockutil"; +import { HelpViewModel } from "@/view/helpview/helpview"; +import { TermViewModel } from "@/view/term/term-model"; +import { WebViewModel } from "@/view/webview/webview"; + +const BlockRegistry: Map = new Map(); +BlockRegistry.set("term", TermViewModel); +BlockRegistry.set("preview", PreviewModel); +BlockRegistry.set("web", WebViewModel); +BlockRegistry.set("cpuplot", SysinfoViewModel); +BlockRegistry.set("sysinfo", SysinfoViewModel); +BlockRegistry.set("vdom", VDomModel); +BlockRegistry.set("tips", QuickTipsViewModel); +BlockRegistry.set("help", HelpViewModel); +BlockRegistry.set("launcher", LauncherViewModel); +BlockRegistry.set("tsunami", TsunamiViewModel); +BlockRegistry.set("waveconfig", WaveConfigViewModel); +BlockRegistry.set("processviewer", ProcessViewerViewModel); +BlockRegistry.set("termblocks", TermBlocksViewModel); + +function makeDefaultViewModel(viewType: string): ViewModel { + const viewModel: ViewModel = { + viewType: viewType, + viewIcon: atom(blockViewToIcon(viewType)), + viewName: atom(blockViewToName(viewType)), + preIconButton: atom(null), + endIconButtons: atom(null), + viewComponent: null, + }; + return viewModel; +} + +function makeViewModel( + blockId: string, + blockView: string, + nodeModel: BlockNodeModel, + tabModel: TabModel, + waveEnv: WaveEnv +): ViewModel { + const ctor = BlockRegistry.get(blockView); + if (ctor != null) { + return new ctor({ blockId, nodeModel, tabModel, waveEnv }); + } + return makeDefaultViewModel(blockView); +} + +export { makeViewModel }; diff --git a/frontend/app/block/blocktypes.ts b/frontend/app/block/blocktypes.ts new file mode 100644 index 000000000..5ea5a3b57 --- /dev/null +++ b/frontend/app/block/blocktypes.ts @@ -0,0 +1,51 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { NodeModel } from "@/layout/index"; +import { Atom } from "jotai"; + +export interface BlockNodeModel { + blockId: string; + isFocused: Atom; + isMagnified: Atom; + onClose: () => void; + focusNode: () => void; + toggleMagnify: () => void; +} + +export type FullBlockProps = { + preview: boolean; + nodeModel: NodeModel; + viewModel: ViewModel; +}; + +export interface BlockProps { + preview: boolean; + nodeModel: NodeModel; +} + +export type FullSubBlockProps = { + nodeModel: BlockNodeModel; + viewModel: ViewModel; +}; + +export interface SubBlockProps { + nodeModel: BlockNodeModel; +} + +export interface BlockComponentModel2 { + onClick?: () => void; + onPointerEnter?: React.PointerEventHandler; + onFocusCapture?: React.FocusEventHandler; + blockRef?: React.RefObject; +} + +export interface BlockFrameProps { + blockModel?: BlockComponentModel2; + nodeModel?: NodeModel; + viewModel?: ViewModel; + preview: boolean; + numBlocksInTab?: number; + children?: React.ReactNode; + connBtnRef?: React.RefObject; +} diff --git a/frontend/app/block/blockutil.tsx b/frontend/app/block/blockutil.tsx new file mode 100644 index 000000000..be725496a --- /dev/null +++ b/frontend/app/block/blockutil.tsx @@ -0,0 +1,277 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Button } from "@/app/element/button"; +import { + MetaKeyAtomFnType, + WaveEnv, + WaveEnvSubset, +} from "@/app/waveenv/waveenv"; +import { IconButton, ToggleIconButton } from "@/element/iconbutton"; +import { MagnifyIcon } from "@/element/magnify"; +import { MenuButton } from "@/element/menubutton"; +import * as util from "@/util/util"; +import clsx from "clsx"; +import * as jotai from "jotai"; +import * as React from "react"; + +export type TabBackgroundEnv = WaveEnvSubset<{ + getTabMetaKeyAtom: MetaKeyAtomFnType<"bg:activebordercolor" | "bg:bordercolor" | "tab:background">; + getConfigBackgroundAtom: WaveEnv["getConfigBackgroundAtom"]; +}>; + +export const colorRegex = /^((#[0-9a-f]{6,8})|([a-z]+))$/; +export const NumActiveConnColors = 8; + +export function blockViewToIcon(view: string): string { + if (view == "term") { + return "terminal"; + } + if (view == "preview") { + return "file"; + } + if (view == "web") { + return "globe"; + } + if (view == "help") { + return "circle-question"; + } + if (view == "tips") { + return "lightbulb"; + } + if (view == "processviewer") { + return "microchip"; + } + return "square"; +} + +export function blockViewToName(view: string): string { + if (util.isBlank(view)) { + return "(No View)"; + } + if (view == "term") { + return "Terminal"; + } + if (view == "preview") { + return "Preview"; + } + if (view == "web") { + return "Web"; + } + if (view == "help") { + return "Help"; + } + if (view == "tips") { + return "Tips"; + } + if (view == "processviewer") { + return "Processes"; + } + return view; +} + +export function processTitleString(titleString: string): React.ReactNode[] { + if (titleString == null) { + return null; + } + const tagRegex = /<(\/)?([a-z]+)(?::([#a-z0-9@-]+))?>/g; + let lastIdx = 0; + let match; + const partsStack = [[]]; + while ((match = tagRegex.exec(titleString)) != null) { + const lastPart = partsStack[partsStack.length - 1]; + const before = titleString.substring(lastIdx, match.index); + lastPart.push(before); + lastIdx = match.index + match[0].length; + const [_, isClosing, tagName, tagParam] = match; + if (tagName == "icon" && !isClosing) { + if (tagParam == null) { + continue; + } + const iconClass = util.makeIconClass(tagParam, false); + if (iconClass == null) { + continue; + } + lastPart.push(); + continue; + } + if (tagName == "c" || tagName == "color") { + if (isClosing) { + if (partsStack.length <= 1) { + continue; + } + partsStack.pop(); + continue; + } + if (tagParam == null) { + continue; + } + if (!tagParam.match(colorRegex)) { + continue; + } + const children = []; + const rtag = React.createElement("span", { key: match.index, style: { color: tagParam } }, children); + lastPart.push(rtag); + partsStack.push(children); + continue; + } + if (tagName == "i" || tagName == "b") { + if (isClosing) { + if (partsStack.length <= 1) { + continue; + } + partsStack.pop(); + continue; + } + const children = []; + const rtag = React.createElement(tagName, { key: match.index }, children); + lastPart.push(rtag); + partsStack.push(children); + continue; + } + } + partsStack[partsStack.length - 1].push(titleString.substring(lastIdx)); + return partsStack[0]; +} + +export function getBlockHeaderIcon(blockIcon: string, overrideIconColor?: string): React.ReactNode { + let blockIconElem: React.ReactNode = null; + if (util.isBlank(blockIcon)) { + blockIcon = "square"; + } + let iconColor = overrideIconColor; + if (iconColor && !iconColor.match(colorRegex)) { + iconColor = null; + } + let iconStyle = null; + if (!util.isBlank(iconColor)) { + iconStyle = { color: iconColor }; + } + const iconClass = util.makeIconClass(blockIcon, true); + if (iconClass != null) { + blockIconElem = ; + } + return blockIconElem; +} + +export function getViewIconElem( + viewIconUnion: string | IconButtonDecl, + overrideIconColor?: string +): React.ReactElement { + if (viewIconUnion == null || typeof viewIconUnion === "string") { + const viewIcon = viewIconUnion as string; + return
{getBlockHeaderIcon(viewIcon, overrideIconColor)}
; + } else { + return ; + } +} + +export function useTabBackground( + waveEnv: TabBackgroundEnv, + tabId: string | null +): [string, string, BackgroundConfigType] { + const tabActiveBorderColorDirect = jotai.useAtomValue(waveEnv.getTabMetaKeyAtom(tabId, "bg:activebordercolor")); + const tabBorderColorDirect = jotai.useAtomValue(waveEnv.getTabMetaKeyAtom(tabId, "bg:bordercolor")); + const tabBg = jotai.useAtomValue(waveEnv.getTabMetaKeyAtom(tabId, "tab:background")); + const configBg = jotai.useAtomValue(waveEnv.getConfigBackgroundAtom(tabBg)); + const tabActiveBorderColor = tabActiveBorderColorDirect ?? configBg?.["bg:activebordercolor"]; + const tabBorderColor = tabBorderColorDirect ?? configBg?.["bg:bordercolor"]; + return [tabBorderColor, tabActiveBorderColor, configBg]; +} + +export const Input = React.memo( + ({ decl, className, preview }: { decl: HeaderInput; className: string; preview: boolean }) => { + const { value, ref, isDisabled, onChange, onKeyDown, onFocus, onBlur } = decl; + return ( +
+ onChange(e)} + onKeyDown={(e) => onKeyDown(e)} + onFocus={(e) => onFocus(e)} + onBlur={(e) => onBlur(e)} + onDragStart={(e) => e.preventDefault()} + /> +
+ ); + } +); + +export const OptMagnifyButton = React.memo( + ({ magnified, toggleMagnify, disabled }: { magnified: boolean; toggleMagnify: () => void; disabled: boolean }) => { + const magnifyDecl: IconButtonDecl = { + elemtype: "iconbutton", + icon: , + title: magnified ? "Minimize" : "Magnify", + click: toggleMagnify, + disabled, + }; + return ; + } +); + +export const HeaderTextElem = React.memo(({ elem, preview }: { elem: HeaderElem; preview: boolean }) => { + if (elem.elemtype == "iconbutton") { + return ; + } else if (elem.elemtype == "toggleiconbutton") { + return ; + } else if (elem.elemtype == "input") { + return ; + } else if (elem.elemtype == "text") { + return ( +
+ elem?.onClick(e)}> + ‎{elem.text} + +
+ ); + } else if (elem.elemtype == "textbutton") { + return ( + + ); + } else if (elem.elemtype == "div") { + return ( +
+ {elem.children.map((child, childIdx) => ( + + ))} +
+ ); + } else if (elem.elemtype == "menubutton") { + return ; + } + return null; +}); + +export function renderHeaderElements(headerTextUnion: HeaderElem[], preview: boolean): React.ReactElement[] { + const headerTextElems: React.ReactElement[] = []; + for (let idx = 0; idx < headerTextUnion.length; idx++) { + const elem = headerTextUnion[idx]; + const renderedElement = ; + if (renderedElement) { + headerTextElems.push(renderedElement); + } + } + return headerTextElems; +} + +export function computeConnColorNum(connStatus: ConnStatus): number { + const connColorNum = (connStatus?.activeconnnum ?? 1) % NumActiveConnColors; + if (connColorNum == 0) { + return NumActiveConnColors; + } + return connColorNum; +} diff --git a/frontend/app/block/connectionbutton.tsx b/frontend/app/block/connectionbutton.tsx new file mode 100644 index 000000000..c5a9b635c --- /dev/null +++ b/frontend/app/block/connectionbutton.tsx @@ -0,0 +1,159 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { computeConnColorNum } from "@/app/block/blockutil"; +import { recordTEvent } from "@/app/store/global"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; +import { IconButton } from "@/element/iconbutton"; +import * as util from "@/util/util"; +import * as jotai from "jotai"; +import * as React from "react"; +import DotsSvg from "../asset/dots-anim-4.svg"; +import { BlockEnv } from "./blockenv"; + +interface ConnectionButtonProps { + connection: string; + changeConnModalAtom: jotai.PrimitiveAtom; + isTerminalBlock?: boolean; +} + +export const ConnectionButton = React.memo( + React.forwardRef( + ({ connection, changeConnModalAtom, isTerminalBlock }: ConnectionButtonProps, ref) => { + const waveEnv = useWaveEnv(); + const [_connModalOpen, setConnModalOpen] = jotai.useAtom(changeConnModalAtom); + const isLocal = util.isLocalConnName(connection); + const connStatus = jotai.useAtomValue(waveEnv.getConnStatusAtom(connection)); + const localName = jotai.useAtomValue(waveEnv.getLocalHostDisplayNameAtom()); + let showDisconnectedSlash = false; + let connIconElem: React.ReactNode = null; + const connColorNum = computeConnColorNum(connStatus); + let color = `var(--conn-icon-color-${connColorNum})`; + const clickHandler = function () { + recordTEvent("action:other", { "action:type": "conndropdown", "action:initiator": "mouse" }); + setConnModalOpen(true); + }; + let titleText = null; + let shouldSpin = false; + let connDisplayName: string = null; + let extraDisplayNameClassName = ""; + if (isLocal) { + color = "var(--color-secondary)"; + if (connection === "local:gitbash") { + titleText = "Connected to Git Bash"; + connDisplayName = "Git Bash"; + } else { + titleText = "Connected to Local Machine"; + if (localName) { + titleText += ` (${localName})`; + } + if (isTerminalBlock) { + connDisplayName = localName; + extraDisplayNameClassName = "text-muted group-hover:text-secondary"; + } + } + connIconElem = ( + + ); + } else { + titleText = "Connected to " + connection; + let iconName = "arrow-right-arrow-left"; + let iconSvg = null; + if (connStatus?.status == "connecting") { + color = "var(--warning-color)"; + titleText = "Connecting to " + connection; + shouldSpin = false; + iconSvg = ( +
+ +
+ ); + } else if (connStatus?.status == "error") { + color = "var(--error-color)"; + titleText = "Error connecting to " + connection; + if (connStatus?.error != null) { + titleText += " (" + connStatus.error + ")"; + } + showDisconnectedSlash = true; + } else if (!connStatus?.connected) { + color = "var(--grey-text-color)"; + titleText = "Disconnected from " + connection; + showDisconnectedSlash = true; + } else if (connStatus?.connhealthstatus === "degraded" || connStatus?.connhealthstatus === "stalled") { + color = "var(--warning-color)"; + iconName = "signal-bars-slash"; + if (connStatus.connhealthstatus === "degraded") { + titleText = "Connection degraded: " + connection; + } else { + titleText = "Connection stalled: " + connection; + } + } + if (iconSvg != null) { + connIconElem = iconSvg; + } else { + connIconElem = ( + + ); + } + } + + const wshProblem = connection && !connStatus?.wshenabled && connStatus?.status == "connected"; + const showNoWshButton = wshProblem && !isLocal; + + return ( + <> +
+ + {connIconElem} + + + {connDisplayName ? ( +
+ {connDisplayName} +
+ ) : isLocal ? null : ( +
{connection}
+ )} +
+ {showNoWshButton && ( + + )} + + ); + } + ) +); +ConnectionButton.displayName = "ConnectionButton"; diff --git a/frontend/app/block/connstatusoverlay.tsx b/frontend/app/block/connstatusoverlay.tsx new file mode 100644 index 000000000..d4d6ad14b --- /dev/null +++ b/frontend/app/block/connstatusoverlay.tsx @@ -0,0 +1,271 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Button } from "@/app/element/button"; +import { CopyButton } from "@/app/element/copybutton"; +import { useDimensionsWithCallbackRef } from "@/app/hook/useDimensions"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; +import { NodeModel } from "@/layout/index"; +import * as util from "@/util/util"; +import clsx from "clsx"; +import * as jotai from "jotai"; +import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; +import * as React from "react"; +import { BlockEnv } from "./blockenv"; + +function formatElapsedTime(elapsedMs: number): string { + if (elapsedMs <= 0) { + return ""; + } + + const elapsedSeconds = Math.floor(elapsedMs / 1000); + + if (elapsedSeconds < 60) { + return `${elapsedSeconds}s`; + } + + const elapsedMinutes = Math.floor(elapsedSeconds / 60); + if (elapsedMinutes < 60) { + return `${elapsedMinutes}m`; + } + + const elapsedHours = Math.floor(elapsedMinutes / 60); + const remainingMinutes = elapsedMinutes % 60; + + if (elapsedHours < 24) { + if (remainingMinutes === 0) { + return `${elapsedHours}h`; + } + return `${elapsedHours}h${remainingMinutes}m`; + } + + return "more than a day"; +} + +const StalledOverlay = React.memo( + ({ + connName, + connStatus, + overlayRefCallback, + }: { + connName: string; + connStatus: ConnStatus; + overlayRefCallback: (el: HTMLDivElement | null) => void; + }) => { + const [elapsedTime, setElapsedTime] = React.useState(""); + + const waveEnv = useWaveEnv(); + const handleDisconnect = React.useCallback(() => { + const prtn = waveEnv.rpc.ConnDisconnectCommand(TabRpcClient, connName, { timeout: 5000 }); + prtn.catch((e) => console.log("error disconnecting", connName, e)); + }, [connName, waveEnv]); + + React.useEffect(() => { + if (!connStatus.lastactivitybeforestalledtime) { + return; + } + + const updateElapsed = () => { + const now = Date.now(); + const lastActivity = connStatus.lastactivitybeforestalledtime!; + const elapsed = now - lastActivity; + setElapsedTime(formatElapsedTime(elapsed)); + }; + + updateElapsed(); + const interval = setInterval(updateElapsed, 1000); + + return () => clearInterval(interval); + }, [connStatus.lastactivitybeforestalledtime]); + + return ( +
+
+ +
+ Connection to "{connName}" is stalled + {elapsedTime && ` (no activity for ${elapsedTime})`} +
+
+ +
+
+ ); + } +); +StalledOverlay.displayName = "StalledOverlay"; + +export const ConnStatusOverlay = React.memo( + ({ + nodeModel, + viewModel, + changeConnModalAtom, + }: { + nodeModel: NodeModel; + viewModel: ViewModel; + changeConnModalAtom: jotai.PrimitiveAtom; + }) => { + const waveEnv = useWaveEnv(); + const connName = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "connection")); + const [connModalOpen] = jotai.useAtom(changeConnModalAtom); + const connStatus = jotai.useAtomValue(waveEnv.getConnStatusAtom(connName)); + const isLayoutMode = jotai.useAtomValue(waveEnv.atoms.controlShiftDelayAtom); + const [overlayRefCallback, _, domRect] = useDimensionsWithCallbackRef(30); + const width = domRect?.width; + const [showError, setShowError] = React.useState(false); + const wshConfigEnabled = + jotai.useAtomValue(waveEnv.getConnConfigKeyAtom(connName, "conn:wshenabled")) ?? true; + const [showWshError, setShowWshError] = React.useState(false); + + React.useEffect(() => { + if (width) { + const hasError = !util.isBlank(connStatus.error); + const showError = hasError && width >= 250 && connStatus.status == "error"; + setShowError(showError); + } + }, [width, connStatus, setShowError]); + + const handleTryReconnect = React.useCallback(() => { + const prtn = waveEnv.rpc.ConnConnectCommand( + TabRpcClient, + { host: connName, logblockid: nodeModel.blockId }, + { timeout: 60000 } + ); + prtn.catch((e) => console.log("error reconnecting", connName, e)); + }, [connName, nodeModel.blockId, waveEnv]); + + const handleDisableWsh = React.useCallback(async () => { + const metamaptype: unknown = { + "conn:wshenabled": false, + }; + const data: ConnConfigRequest = { + host: connName, + metamaptype: metamaptype, + }; + try { + await waveEnv.rpc.SetConnectionsConfigCommand(TabRpcClient, data); + } catch (e) { + console.log("problem setting connection config: ", e); + } + }, [connName, waveEnv]); + + const handleRemoveWshError = React.useCallback(async () => { + try { + await waveEnv.rpc.DismissWshFailCommand(TabRpcClient, connName); + } catch (e) { + console.log("unable to dismiss wsh error: ", e); + } + }, [connName, waveEnv]); + + let statusText = `Disconnected from "${connName}"`; + let showReconnect = true; + if (connStatus.status == "connecting") { + statusText = `Connecting to "${connName}"...`; + showReconnect = false; + } + if (connStatus.status == "connected") { + showReconnect = false; + } + let reconDisplay = null; + let reconClassName = "outlined grey"; + if (width && width < 350) { + reconDisplay = ; + reconClassName = clsx(reconClassName, "text-[12px] py-[5px] px-[6px]"); + } else { + reconDisplay = "Reconnect"; + reconClassName = clsx(reconClassName, "text-[11px] py-[3px] px-[7px]"); + } + const showIcon = connStatus.status != "connecting"; + + React.useEffect(() => { + const showWshErrorTemp = + connStatus.status == "connected" && + connStatus.wsherror && + connStatus.wsherror != "" && + wshConfigEnabled; + + setShowWshError(showWshErrorTemp); + }, [connStatus, wshConfigEnabled]); + + const handleCopy = React.useCallback( + async (e: React.MouseEvent) => { + const errTexts = []; + if (showError) { + errTexts.push(`error: ${connStatus.error}`); + } + if (showWshError) { + errTexts.push(`unable to use wsh: ${connStatus.wsherror}`); + } + const textToCopy = errTexts.join("\n"); + await navigator.clipboard.writeText(textToCopy); + }, + [showError, showWshError, connStatus.error, connStatus.wsherror] + ); + + const showStalled = connStatus.status == "connected" && connStatus.connhealthstatus == "stalled"; + if (!showWshError && !showStalled && (isLayoutMode || connStatus.status == "connected" || connModalOpen)) { + return null; + } + + if (showStalled && !showWshError) { + return ( + + ); + } + + return ( +
+
+
+ {showIcon && } +
+
{statusText}
+ {(showError || showWshError) && ( + + + {showError ?
error: {connStatus.error}
: null} + {showWshError ?
unable to use wsh: {connStatus.wsherror}
: null} +
+ )} + {showWshError && ( + + )} +
+
+ {showReconnect ? ( +
+ +
+ ) : null} + {showWshError ? ( +
+
+ ) : null} +
+
+ ); + } +); +ConnStatusOverlay.displayName = "ConnStatusOverlay"; diff --git a/frontend/app/block/durable-session-flyover.tsx b/frontend/app/block/durable-session-flyover.tsx new file mode 100644 index 000000000..7ab7fa0b1 --- /dev/null +++ b/frontend/app/block/durable-session-flyover.tsx @@ -0,0 +1,440 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { recordTEvent } from "@/app/store/global"; +import { TermViewModel } from "@/app/view/term/term-model"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; +import * as util from "@/util/util"; +import { cn } from "@/util/util"; +import { + autoUpdate, + flip, + FloatingPortal, + offset, + safePolygon, + shift, + useFloating, + useHover, + useInteractions, +} from "@floating-ui/react"; +import * as jotai from "jotai"; +import { useEffect, useRef, useState } from "react"; +import { BlockEnv } from "./blockenv"; + +function isTermViewModel(viewModel: ViewModel): viewModel is TermViewModel { + return viewModel?.viewType === "term"; +} + +function LearnMoreButton() { + const waveEnv = useWaveEnv(); + return ( + + ); +} + +interface StandardSessionContentProps { + viewModel: TermViewModel; + onClose: () => void; +} + +function StandardSessionContent({ viewModel, onClose }: StandardSessionContentProps) { + const handleRestartAsDurable = () => { + recordTEvent("action:termdurable", { "action:type": "restartdurable" }); + onClose(); + util.fireAndForget(() => viewModel.restartSessionWithDurability(true)); + }; + + return ( +
+
+ + Standard SSH Session +
+
+ Standard SSH sessions end when the connection drops. Durable sessions keep your shell state, running + programs, and history alive through network changes, computer sleep, and Wave restarts. +
+ + +
+ ); +} + +interface DurableAttachedContentProps { + onClose: () => void; +} + +function DurableAttachedContent({ onClose }: DurableAttachedContentProps) { + return ( +
+
+ + Durable Session (Attached) +
+
+ Your shell state, running programs, and history are protected. This session will survive network + disconnects. +
+ +
+ ); +} + +interface DurableDetachedContentProps { + onClose: () => void; +} + +function DurableDetachedContent({ onClose }: DurableDetachedContentProps) { + return ( +
+
+ + Durable Session (Detached) +
+
+ Connection lost, but your session is still running on the remote server. Wave will automatically + reconnect when the connection is restored. +
+ +
+ ); +} + +interface DurableAwaitingStartProps { + connected: boolean; + viewModel: TermViewModel; + onClose: () => void; +} + +function DurableAwaitingStart({ connected, viewModel, onClose }: DurableAwaitingStartProps) { + const handleStartSession = () => { + onClose(); + util.fireAndForget(() => viewModel.forceRestartController()); + }; + + if (!connected) { + return ( +
+
+ + Durable Session (Awaiting Connection) +
+
+ Configured for a durable session. The session will start when the connection is established. +
+ +
+ ); + } + + return ( +
+
+ + Durable Session (Awaiting Start) +
+
+ Configured for a durable session, but session hasn't started yet. Click below to start it manually. +
+ + +
+ ); +} + +interface DurableStartingContentProps { + onClose: () => void; +} + +function DurableStartingContent({ onClose }: DurableStartingContentProps) { + return ( +
+
+ + Durable Session (Starting) +
+
The durable session is starting.
+ +
+ ); +} + +interface DurableEndedContentProps { + doneReason: string; + startupError?: string; + viewModel: TermViewModel; + onClose: () => void; +} + +function DurableEndedContent({ doneReason, startupError, viewModel, onClose }: DurableEndedContentProps) { + const handleRestartSession = () => { + onClose(); + util.fireAndForget(() => viewModel.forceRestartController()); + }; + + const handleRestartAsStandard = () => { + onClose(); + util.fireAndForget(() => viewModel.restartSessionWithDurability(false)); + }; + + let titleText = "Durable Session (Ended)"; + let descriptionText = "The durable session has ended. This block is still configured for durable sessions."; + const showRestartButton = true; + + if (doneReason === "terminated") { + titleText = "Durable Session (Ended, Exited)"; + descriptionText = + "The shell was terminated and is no longer running. This block is still configured for durable sessions."; + } else if (doneReason === "gone") { + titleText = "Durable Session (Ended, Lost)"; + descriptionText = + "The session was lost or not found on the remote server. This may have occurred due to a system reboot or the session being manually terminated."; + } else if (doneReason === "startuperror") { + titleText = "Durable Session (Failed to Start)"; + descriptionText = "The durable session failed to start."; + return ( +
+
+ + {titleText} +
+
{descriptionText}
+ {startupError && ( +
+ {startupError} +
+ )} + + + +
+ ); + } + + return ( +
+
+ + {titleText} +
+
{descriptionText}
+ {showRestartButton && ( + + )} + +
+ ); +} + +function getContentToRender( + viewModel: TermViewModel, + onClose: () => void, + jobStatus: BlockJobStatusData, + connStatus: ConnStatus, + isConfigedDurable?: boolean | null +): string | React.ReactNode { + if (isConfigedDurable === false) { + return ; + } + + const status = jobStatus?.status; + if (status === "connected") { + return ; + } else if (status === "disconnected") { + return ; + } else if (status === "init") { + return ; + } else if (status === "done") { + const doneReason = jobStatus?.donereason; + const startupError = jobStatus?.startuperror; + return ( + + ); + } else if (status == null) { + return ; + } + console.log("DurableSessionFlyover: unexpected jobStatus", jobStatus); + return null; +} + +function getIconProps(jobStatus: BlockJobStatusData, connStatus: ConnStatus, isConfigedDurable?: boolean | null) { + let color = "text-muted"; + let iconType: "fa-solid" | "fa-regular" = "fa-solid"; + + if (isConfigedDurable === false) { + color = "text-muted"; + iconType = "fa-regular"; + return { color, iconType }; + } + + const status = jobStatus?.status; + if (status === "connected") { + color = "text-sky-500"; + } else if (status === "disconnected") { + color = "text-sky-300"; + } else if (status === "init") { + color = "text-sky-300"; + } else if (status === "done") { + color = "text-muted"; + } else if (status == null) { + color = "text-muted"; + } + return { color, iconType }; +} + +interface DurableSessionFlyoverProps { + blockId: string; + viewModel: ViewModel; + placement?: "top" | "bottom" | "left" | "right"; + divClassName?: string; +} + +export function DurableSessionFlyover({ + blockId, + viewModel, + placement = "bottom", + divClassName, +}: DurableSessionFlyoverProps) { + const waveEnv = useWaveEnv(); + const connName = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(blockId, "connection")); + const termDurableStatus = util.useAtomValueSafe(viewModel?.termDurableStatus); + const termConfigedDurable = util.useAtomValueSafe(viewModel?.termConfigedDurable); + const connStatus = jotai.useAtomValue(waveEnv.getConnStatusAtom(connName)); + + const { color: durableIconColor, iconType: durableIconType } = getIconProps( + termDurableStatus, + connStatus, + termConfigedDurable + ); + + const [isOpen, setIsOpen] = useState(false); + const [isVisible, setIsVisible] = useState(false); + const timeoutRef = useRef(null); + + const handleClose = () => { + setIsVisible(false); + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + timeoutRef.current = window.setTimeout(() => { + setIsOpen(false); + }, 300); + }; + + const { refs, floatingStyles, context } = useFloating({ + open: isOpen, + onOpenChange: (open) => { + if (open) { + setIsOpen(true); + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + timeoutRef.current = window.setTimeout(() => { + setIsVisible(true); + }, 300); + } else { + setIsVisible(false); + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + timeoutRef.current = window.setTimeout(() => { + setIsOpen(false); + }, 300); + } + }, + placement, + middleware: [offset(10), flip(), shift({ padding: 12 })], + whileElementsMounted: autoUpdate, + }); + + useEffect(() => { + return () => { + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + }; + }, []); + + const hover = useHover(context, { + handleClose: safePolygon(), + }); + const { getReferenceProps, getFloatingProps } = useInteractions([hover]); + + if (!isTermViewModel(viewModel)) { + return null; + } + + const content = getContentToRender(viewModel, handleClose, termDurableStatus, connStatus, termConfigedDurable); + if (content == null) { + return null; + } + + return ( + <> +
+ +
+ {isOpen && ( + +
e.stopPropagation()} + onFocusCapture={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + {content} +
+
+ )} + + ); +} diff --git a/frontend/app/codereview/git-model.ts b/frontend/app/codereview/git-model.ts new file mode 100644 index 000000000..4a9a16596 --- /dev/null +++ b/frontend/app/codereview/git-model.ts @@ -0,0 +1,553 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { focusedCwdAtom } from "@/app/fileexplorer/file-explorer-atoms"; +import { globalStore } from "@/app/store/jotaiStore"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { fireAndForget } from "@/util/util"; +import { getApi } from "@/store/global"; +import { debounce } from "throttle-debounce"; +import * as jotai from "jotai"; + +export type GitChangedFile = { + status: string; + path: string; + origPath?: string; +}; + +export type DiffLine = { + type: "header" | "hunk" | "add" | "remove" | "context"; + content: string; +}; + +export type FileStats = { + add: number; + del: number; +}; + +export type DiffMode = "Head" | "MainBranch" | "Other"; + +export type ReviewComment = { + id: string; + path: string; + // Line number in the new (post-change) file the comment is anchored to. + // null means file-level (not tied to a specific line). + line: number | null; + body: string; + createdAt: number; +}; + +// Status group ordering for the review panel — modified files first, then +// added, then deleted, then renamed. The `status` field of GitChangedFile +// is the two-char porcelain code from `git status --short`. +export function statusGroup(status: string): "modified" | "added" | "deleted" | "renamed" | "other" { + const s = status.trim(); + if (s.startsWith("R")) return "renamed"; + if (s === "??" || s === "A" || s.startsWith("A")) return "added"; + if (s === "D" || s.startsWith("D")) return "deleted"; + if (s === "M" || s.startsWith("M") || s === "MM" || s.endsWith("M")) return "modified"; + return "other"; +} + +const StatusGroupOrder: Record = { + modified: 0, + added: 1, + deleted: 2, + renamed: 3, + other: 4, +}; + +export function sortFilesForReview(files: GitChangedFile[]): GitChangedFile[] { + return [...files].sort((a, b) => { + const ga = StatusGroupOrder[statusGroup(a.status)]; + const gb = StatusGroupOrder[statusGroup(b.status)]; + if (ga !== gb) return ga - gb; + return a.path.localeCompare(b.path); + }); +} + +export function parseStatusOutput(raw: string): GitChangedFile[] { + const files: GitChangedFile[] = []; + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + const status = line.slice(0, 2).trim() || "M"; + const rest = line.slice(3); + if (!rest) continue; + if (rest.includes(" -> ")) { + const [origPath, path] = rest.split(" -> "); + files.push({ status, path, origPath }); + } else { + files.push({ status, path: rest }); + } + } + return files; +} + +// `git diff --name-status` output, one file per line: +// M\tpath/to/file +// A\tnew/file +// D\tdeleted/file +// R100\told/path\tnew/path +export function parseNameStatusOutput(raw: string): GitChangedFile[] { + const files: GitChangedFile[] = []; + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + const parts = line.split("\t"); + if (parts.length < 2) continue; + const code = parts[0]; + if (code.startsWith("R") && parts.length >= 3) { + files.push({ status: "R", path: parts[2], origPath: parts[1] }); + } else if (code.startsWith("C") && parts.length >= 3) { + files.push({ status: "C", path: parts[2], origPath: parts[1] }); + } else { + files.push({ status: code, path: parts.slice(1).join("\t") }); + } + } + return files; +} + +export function parseDiffOutput(raw: string): DiffLine[] { + const lines: DiffLine[] = []; + for (const line of raw.split("\n")) { + if (line.startsWith("diff --git") || line.startsWith("index ") || line.startsWith("--- ") || line.startsWith("+++ ")) { + lines.push({ type: "header", content: line }); + } else if (line.startsWith("@@")) { + lines.push({ type: "hunk", content: line }); + } else if (line.startsWith("+")) { + lines.push({ type: "add", content: line.slice(1) }); + } else if (line.startsWith("-")) { + lines.push({ type: "remove", content: line.slice(1) }); + } else if (line.startsWith(" ")) { + lines.push({ type: "context", content: line.slice(1) }); + } + } + return lines; +} + +export function countStats(lines: DiffLine[]): FileStats { + let add = 0; + let del = 0; + for (const l of lines) { + if (l.type === "add") add++; + if (l.type === "remove") del++; + } + return { add, del }; +} + +export class GitModel { + private static instance: GitModel | null = null; + + isRepoAtom: jotai.PrimitiveAtom; + branchAtom: jotai.PrimitiveAtom; + mainBranchAtom: jotai.PrimitiveAtom; + totalAddAtom: jotai.PrimitiveAtom; + totalDelAtom: jotai.PrimitiveAtom; + filesAtom: jotai.PrimitiveAtom; + expandedFilesAtom: jotai.PrimitiveAtom>; + fileDiffsAtom: jotai.PrimitiveAtom>; + fileStatsAtom: jotai.PrimitiveAtom>; + loadingAtom: jotai.PrimitiveAtom; + loadingFilesAtom: jotai.PrimitiveAtom>; + errorAtom: jotai.PrimitiveAtom; + cwdAtom: jotai.PrimitiveAtom; + diffModeAtom: jotai.PrimitiveAtom; + selectedFileAtom: jotai.PrimitiveAtom; + fileSidebarCollapsedAtom: jotai.PrimitiveAtom; + commentsAtom: jotai.PrimitiveAtom; + + private watchedGitDir: string | null = null; + private watchedCwd: string | null = null; + private gitDirCallback: (() => void) | null = null; + private cwdCallback: (() => void) | null = null; + private debouncedRefresh!: () => void; + + private constructor() { + this.isRepoAtom = jotai.atom(false) as jotai.PrimitiveAtom; + this.branchAtom = jotai.atom("") as jotai.PrimitiveAtom; + this.mainBranchAtom = jotai.atom("") as jotai.PrimitiveAtom; + this.totalAddAtom = jotai.atom(0) as jotai.PrimitiveAtom; + this.totalDelAtom = jotai.atom(0) as jotai.PrimitiveAtom; + this.filesAtom = jotai.atom([]) as jotai.PrimitiveAtom; + this.expandedFilesAtom = jotai.atom(new Set()) as jotai.PrimitiveAtom>; + this.fileDiffsAtom = jotai.atom(new Map()) as jotai.PrimitiveAtom>; + this.fileStatsAtom = jotai.atom(new Map()) as jotai.PrimitiveAtom>; + this.loadingAtom = jotai.atom(false) as jotai.PrimitiveAtom; + this.loadingFilesAtom = jotai.atom(new Set()) as jotai.PrimitiveAtom>; + this.errorAtom = jotai.atom(null) as jotai.PrimitiveAtom; + this.cwdAtom = jotai.atom("~") as jotai.PrimitiveAtom; + this.diffModeAtom = jotai.atom("Head") as jotai.PrimitiveAtom; + this.selectedFileAtom = jotai.atom(null) as jotai.PrimitiveAtom; + this.fileSidebarCollapsedAtom = jotai.atom(false) as jotai.PrimitiveAtom; + this.commentsAtom = jotai.atom([]) as jotai.PrimitiveAtom; + this.debouncedRefresh = debounce(1000, () => fireAndForget(() => this.refresh())); + } + + startAutoRefresh(): void { + const cwd = globalStore.get(this.cwdAtom); + const gitDir = `${cwd}/.git`; + if (this.watchedGitDir === gitDir && this.watchedCwd === cwd) return; + this.stopAutoRefresh(); + // Watch the .git directory — any git operation (add, commit, checkout...) + // modifies files under .git, triggering an immediate refresh. + this.gitDirCallback = () => this.debouncedRefresh(); + getApi().watchDir(gitDir, this.gitDirCallback); + this.watchedGitDir = gitDir; + // Also watch the working tree root for new/deleted untracked files. + this.cwdCallback = () => this.debouncedRefresh(); + getApi().watchDir(cwd, this.cwdCallback); + this.watchedCwd = cwd; + } + + stopAutoRefresh(): void { + if (this.watchedGitDir && this.gitDirCallback) { + getApi().unwatchDir(this.watchedGitDir, this.gitDirCallback); + } + this.watchedGitDir = null; + this.gitDirCallback = null; + if (this.watchedCwd && this.cwdCallback) { + getApi().unwatchDir(this.watchedCwd, this.cwdCallback); + } + this.watchedCwd = null; + this.cwdCallback = null; + } + + static getInstance(): GitModel { + if (!GitModel.instance) { + GitModel.instance = new GitModel(); + } + return GitModel.instance; + } + + syncCwd(): void { + const cwd = globalStore.get(focusedCwdAtom); + if (cwd && cwd !== globalStore.get(this.cwdAtom)) { + globalStore.set(this.cwdAtom, cwd); + globalStore.set(this.expandedFilesAtom, new Set()); + globalStore.set(this.fileDiffsAtom, new Map()); + globalStore.set(this.fileStatsAtom, new Map()); + // Re-install watchers against the new cwd if auto-refresh was active. + if (this.watchedGitDir || this.watchedCwd) { + this.startAutoRefresh(); + } + } + } + + // detectMainBranch resolves the repo's main/integration branch. + // Order: origin/HEAD symbolic-ref → main → master → empty (no remote default). + private async detectMainBranch(cwd: string): Promise { + try { + const r = await RpcApi.RunLocalCmdCommand(TabRpcClient, { + cmd: "git", + args: ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], + cwd, + }); + const ref = r.stdout.trim(); + if (ref) return ref; + } catch { + // expected when origin/HEAD isn't set + } + for (const candidate of ["origin/main", "origin/master", "main", "master"]) { + try { + const r = await RpcApi.RunLocalCmdCommand(TabRpcClient, { + cmd: "git", + args: ["rev-parse", "--verify", "--quiet", candidate], + cwd, + }); + if (r.exitcode === 0 && r.stdout.trim()) return candidate; + } catch { + // try next candidate + } + } + return ""; + } + + // Comparison ref used by diff commands. For Head we diff against the + // current HEAD (working tree changes). For MainBranch we use three-dot + // syntax (`
...HEAD`) which mirrors GitHub's PR view: commits on this + // branch since it diverged. Other isn't wired up yet — it falls back to + // Head with a console warning so the panel still renders. + private comparisonRef(): string { + const mode = globalStore.get(this.diffModeAtom); + if (mode === "Head") return "HEAD"; + if (mode === "MainBranch") { + const main = globalStore.get(this.mainBranchAtom); + return main ? `${main}...HEAD` : "HEAD"; + } + return "HEAD"; + } + + async refresh(): Promise { + const cwd = globalStore.get(this.cwdAtom); + if (!cwd) return; + globalStore.set(this.loadingAtom, true); + globalStore.set(this.errorAtom, null); + try { + const [info, mainBranch] = await Promise.all([ + RpcApi.GetGitInfoCommand(TabRpcClient, cwd), + this.detectMainBranch(cwd), + ]); + globalStore.set(this.isRepoAtom, info.isrepo); + globalStore.set(this.branchAtom, info.branch ?? ""); + globalStore.set(this.mainBranchAtom, mainBranch); + if (info.isrepo) { + const files = await this.loadFiles(); + globalStore.set(this.filesAtom, files); + this.maybeAutoSelectFile(files); + // Populate per-file stats up front so the panel shows + // real counts (not +0/-0) before the user expands a row. + fireAndForget(() => this.loadAllStats(files)); + // Reload diff for the currently selected file, plus any + // already-expanded rows in the file list. + const selected = globalStore.get(this.selectedFileAtom); + if (selected) fireAndForget(() => this.loadDiff(selected)); + const expanded = globalStore.get(this.expandedFilesAtom); + for (const path of expanded) { + if (path !== selected) fireAndForget(() => this.loadDiff(path)); + } + // Compute total +/- for the current diff mode. + this.recomputeTotalsFromStats(); + } else { + globalStore.set(this.totalAddAtom, info.additions ?? 0); + globalStore.set(this.totalDelAtom, info.deletions ?? 0); + } + } catch (e: any) { + globalStore.set(this.errorAtom, e?.message ?? String(e)); + } finally { + globalStore.set(this.loadingAtom, false); + } + } + + // loadFiles returns the file list for the current diff mode. Head uses + // `git status` so untracked files surface; MainBranch uses `git diff + // --name-status` so the listing matches the PR-style comparison. + private async loadFiles(): Promise { + const cwd = globalStore.get(this.cwdAtom); + const mode = globalStore.get(this.diffModeAtom); + if (mode === "Head" || mode === "Other") { + const statusResult = await RpcApi.RunLocalCmdCommand(TabRpcClient, { + cmd: "git", + args: ["status", "--short", "--porcelain"], + cwd, + }); + return sortFilesForReview(parseStatusOutput(statusResult.stdout)); + } + // MainBranch — name-status against the comparison ref. + const ref = this.comparisonRef(); + const r = await RpcApi.RunLocalCmdCommand(TabRpcClient, { + cmd: "git", + args: ["diff", "--name-status", ref], + cwd, + }); + return sortFilesForReview(parseNameStatusOutput(r.stdout)); + } + + private maybeAutoSelectFile(files: GitChangedFile[]): void { + const selected = globalStore.get(this.selectedFileAtom); + if (selected && files.some((f) => f.path === selected)) return; + if (files.length === 0) { + globalStore.set(this.selectedFileAtom, null); + return; + } + const first = files[0].path; + globalStore.set(this.selectedFileAtom, first); + fireAndForget(() => this.loadDiff(first)); + } + + private recomputeTotalsFromStats(): void { + const stats = globalStore.get(this.fileStatsAtom); + let add = 0; + let del = 0; + for (const s of stats.values()) { + add += s.add; + del += s.del; + } + globalStore.set(this.totalAddAtom, add); + globalStore.set(this.totalDelAtom, del); + } + + setDiffMode(mode: DiffMode): void { + if (globalStore.get(this.diffModeAtom) === mode) return; + globalStore.set(this.diffModeAtom, mode); + // Reset diff-derived state so the new mode's data lands cleanly. + globalStore.set(this.fileDiffsAtom, new Map()); + globalStore.set(this.fileStatsAtom, new Map()); + globalStore.set(this.expandedFilesAtom, new Set()); + fireAndForget(() => this.refresh()); + } + + selectFile(path: string | null): void { + globalStore.set(this.selectedFileAtom, path); + if (!path) return; + const diffs = globalStore.get(this.fileDiffsAtom); + if (!diffs.has(path)) { + fireAndForget(() => this.loadDiff(path)); + } + } + + toggleFileSidebar(): void { + globalStore.set(this.fileSidebarCollapsedAtom, !globalStore.get(this.fileSidebarCollapsedAtom)); + } + + addComment(path: string, line: number | null, body: string): void { + const trimmed = body.trim(); + if (!trimmed) return; + const comment: ReviewComment = { + id: `c-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + path, + line, + body: trimmed, + createdAt: Date.now(), + }; + const next = [...globalStore.get(this.commentsAtom), comment]; + globalStore.set(this.commentsAtom, next); + } + + removeComment(id: string): void { + const next = globalStore.get(this.commentsAtom).filter((c) => c.id !== id); + globalStore.set(this.commentsAtom, next); + } + + clearComments(): void { + globalStore.set(this.commentsAtom, []); + } + + // loadAllStats fetches +/- counts for every changed file in one + // pass. Tracked changes come from `git diff --numstat HEAD` (single + // call, tiny output). Untracked files don't appear in numstat, so + // we fall back to `wc -l` per file — counting all their lines as + // additions, which matches how loadDiff treats them. Binary files + // show "-\t-" in numstat; we coerce to 0/0 since FileStats doesn't + // model binary specially. + private async loadAllStats(files: GitChangedFile[]): Promise { + const cwd = globalStore.get(this.cwdAtom); + if (!cwd || files.length === 0) return; + const ref = this.comparisonRef(); + const stats = new Map(); + try { + const numstatResult = await RpcApi.RunLocalCmdCommand(TabRpcClient, { + cmd: "git", + args: ["diff", "--numstat", ref], + cwd, + }); + for (const line of numstatResult.stdout.split("\n")) { + if (!line) continue; + const [addStr, delStr, ...pathParts] = line.split("\t"); + const path = pathParts.join("\t"); + if (!path) continue; + const a = parseInt(addStr, 10); + const d = parseInt(delStr, 10); + stats.set(path, { add: isNaN(a) ? 0 : a, del: isNaN(d) ? 0 : d }); + } + } catch (e: any) { + console.warn(`git diff --numstat failed:`, e); + } + // Untracked files (and anything numstat skipped) — count their + // lines as additions. Run sequentially to keep this cheap; a + // typical changeset has at most a handful of untracked files. + const untracked = files.filter((f) => !stats.has(f.path)); + for (const f of untracked) { + try { + const r = await RpcApi.RunLocalCmdCommand(TabRpcClient, { + cmd: "wc", + args: ["-l", f.path], + cwd, + }); + const n = parseInt(r.stdout.trim().split(/\s+/)[0], 10); + stats.set(f.path, { add: isNaN(n) ? 0 : n, del: 0 }); + } catch { + stats.set(f.path, { add: 0, del: 0 }); + } + } + // Merge with any existing stats from already-loaded full diffs + // — those are equivalent to numstat values, but the merge keeps + // a per-file race (loadDiff finishing after loadAllStats) from + // dropping richer entries. + const merged = new Map(globalStore.get(this.fileStatsAtom)); + for (const [path, s] of stats) { + merged.set(path, s); + } + globalStore.set(this.fileStatsAtom, merged); + this.recomputeTotalsFromStats(); + } + + private async loadDiff(path: string): Promise { + const cwd = globalStore.get(this.cwdAtom); + const ref = this.comparisonRef(); + const loading = new Set(globalStore.get(this.loadingFilesAtom)); + loading.add(path); + globalStore.set(this.loadingFilesAtom, loading); + try { + const result = await RpcApi.RunLocalCmdCommand(TabRpcClient, { + cmd: "git", + args: ["diff", "--unified=3", ref, "--", path], + cwd, + }); + let diffText = result.stdout; + // If file is untracked, diff against /dev/null + if (!diffText && result.exitcode !== 0) { + diffText = result.stderr; + } + const lines = parseDiffOutput(diffText); + const stats = countStats(lines); + const diffs = new Map(globalStore.get(this.fileDiffsAtom)); + diffs.set(path, lines); + const fileStats = new Map(globalStore.get(this.fileStatsAtom)); + fileStats.set(path, stats); + globalStore.set(this.fileDiffsAtom, diffs); + globalStore.set(this.fileStatsAtom, fileStats); + } catch (e: any) { + console.warn(`git diff ${path} failed:`, e); + } finally { + const l = new Set(globalStore.get(this.loadingFilesAtom)); + l.delete(path); + globalStore.set(this.loadingFilesAtom, l); + } + } + + async toggleExpand(path: string): Promise { + const expanded = new Set(globalStore.get(this.expandedFilesAtom)); + if (expanded.has(path)) { + expanded.delete(path); + globalStore.set(this.expandedFilesAtom, expanded); + return; + } + expanded.add(path); + globalStore.set(this.expandedFilesAtom, expanded); + const diffs = globalStore.get(this.fileDiffsAtom); + if (!diffs.has(path)) { + await this.loadDiff(path); + } + } + + expandAll(): void { + const files = globalStore.get(this.filesAtom); + const expanded = new Set(files.map((f) => f.path)); + globalStore.set(this.expandedFilesAtom, expanded); + for (const f of files) { + const diffs = globalStore.get(this.fileDiffsAtom); + if (!diffs.has(f.path)) { + fireAndForget(() => this.loadDiff(f.path)); + } + } + } + + collapseAll(): void { + globalStore.set(this.expandedFilesAtom, new Set()); + } + + async discardFile(path: string, opts?: { skipRefresh?: boolean }): Promise { + const cwd = globalStore.get(this.cwdAtom); + await RpcApi.RunLocalCmdCommand(TabRpcClient, { + cmd: "git", + args: ["checkout", "--", path], + cwd, + }); + if (!opts?.skipRefresh) await this.refresh(); + } + + async discardFiles(paths: string[]): Promise { + await Promise.all(paths.map((p) => this.discardFile(p, { skipRefresh: true }))); + await this.refresh(); + } +} diff --git a/frontend/app/codereview/git-panel.tsx b/frontend/app/codereview/git-panel.tsx new file mode 100644 index 000000000..c641631fd --- /dev/null +++ b/frontend/app/codereview/git-panel.tsx @@ -0,0 +1,1001 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { ContextMenuModel } from "@/app/store/contextmenu"; +import { globalStore } from "@/app/store/jotaiStore"; +import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; +import { UIcon } from "@/app/element/ui-icon"; +import { cn, fireAndForget } from "@/util/util"; +import { getApi } from "@/store/global"; +import { useAtomValue } from "jotai"; +import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { bundledLanguages, codeToHtml } from "shiki/bundle/web"; +import { + DiffLine, + DiffMode, + FileStats, + GitChangedFile, + GitModel, + ReviewComment, + statusGroup, +} from "./git-model"; + +const ShikiTheme = "github-dark-high-contrast"; +const FileSidebar_DefaultWidth = 250; +const FileSidebar_MinWidth = 160; +const FileSidebar_MaxWidth = 480; + +// ---- Extension → Shiki language mapping ---- +const ExtToShikiLang: Record = { + ts: "typescript", + tsx: "tsx", + js: "javascript", + jsx: "jsx", + mjs: "javascript", + cjs: "javascript", + go: "go", + rs: "rust", + py: "python", + rb: "ruby", + java: "java", + kt: "kotlin", + swift: "swift", + c: "c", + h: "c", + cpp: "cpp", + cc: "cpp", + cxx: "cpp", + hpp: "cpp", + cs: "csharp", + php: "php", + sh: "bash", + bash: "bash", + zsh: "bash", + yml: "yaml", + yaml: "yaml", + json: "json", + toml: "toml", + xml: "xml", + html: "html", + css: "css", + scss: "scss", + sass: "sass", + less: "less", + md: "markdown", + sql: "sql", + vue: "vue", + svelte: "svelte", + lua: "lua", + dart: "dart", + proto: "proto", + r: "r", + scala: "scala", + hs: "haskell", + ex: "elixir", + exs: "elixir", +}; + +function resolveShikiLang(path: string): string | null { + const name = path.split("/").pop()?.toLowerCase() ?? ""; + if (name === "dockerfile") return "dockerfile" in bundledLanguages ? "dockerfile" : null; + if (name === "makefile") return "makefile" in bundledLanguages ? "makefile" : null; + const ext = name.includes(".") ? name.split(".").pop()! : ""; + const lang = ExtToShikiLang[ext]; + return lang && lang in bundledLanguages ? lang : null; +} + +// ---- Header icon button ---- +const HeaderIconButton = memo( + ({ + icon, + onClick, + title, + danger, + active, + }: { + icon: string; + onClick: (e: React.MouseEvent) => void; + title?: string; + danger?: boolean; + active?: boolean; + }) => ( + + ) +); +HeaderIconButton.displayName = "HeaderIconButton"; + +// ---- Status badge label (modified / added / deleted / renamed) ---- +function statusLabel(status: string): { label: string; color: string } | null { + const g = statusGroup(status); + if (g === "modified") return null; + if (g === "added") return { label: "Added", color: "text-emerald-400" }; + if (g === "deleted") return { label: "Deleted", color: "text-rose-400" }; + if (g === "renamed") return { label: "Renamed", color: "text-sky-400" }; + return null; +} + +// ---- Stat badge: `+494 −0` ---- +const StatBadge = memo( + ({ + add, + del, + loading, + muted, + }: { + add: number; + del: number; + loading?: boolean; + muted?: boolean; + }) => { + if (loading) { + return ; + } + if (add === 0 && del === 0) return null; + const addClass = muted ? "text-emerald-300/80" : "text-emerald-400"; + const delClass = muted ? "text-rose-300/80" : "text-rose-400"; + return ( + + {add > 0 && +{add}} + {del > 0 && −{del}} + + ); + } +); +StatBadge.displayName = "StatBadge"; + +// ---- Diff mode selector ---- +function diffModeLabel(mode: DiffMode, mainBranch: string): string { + if (mode === "Head") return "Uncommitted changes"; + if (mode === "MainBranch") return mainBranch ? `vs. ${mainBranch}` : "vs. main"; + return "Other branchâ€Ļ"; +} + +const DiffModeSelector = memo(({ mode, mainBranch }: { mode: DiffMode; mainBranch: string }) => { + const model = GitModel.getInstance(); + const handleClick = useCallback( + (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + const items: ContextMenuItem[] = [ + { + label: "Uncommitted changes", + type: "checkbox", + checked: mode === "Head", + click: () => model.setDiffMode("Head"), + }, + { + label: mainBranch ? `vs. ${mainBranch}` : "vs. main (no remote default)", + type: "checkbox", + checked: mode === "MainBranch", + click: () => model.setDiffMode("MainBranch"), + }, + ]; + ContextMenuModel.getInstance().showContextMenu(items, e, { + position: { x: rect.left, y: rect.bottom + 4 }, + }); + }, + [mode, mainBranch, model] + ); + return ( + + ); +}); +DiffModeSelector.displayName = "DiffModeSelector"; + +// ---- Compute per-line old/new numbers by walking hunk headers ---- +type NumberedLine = { line: DiffLine; oldNum?: number; newNum?: number }; + +function numberDiffLines(diff: DiffLine[]): NumberedLine[] { + const out: NumberedLine[] = []; + let oldNum = 0; + let newNum = 0; + const hunkRe = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/; + for (const line of diff) { + if (line.type === "hunk") { + const m = hunkRe.exec(line.content); + if (m) { + oldNum = parseInt(m[1], 10); + newNum = parseInt(m[2], 10); + } + out.push({ line }); + continue; + } + if (line.type === "header") { + out.push({ line }); + continue; + } + if (line.type === "add") { + out.push({ line, newNum }); + newNum++; + } else if (line.type === "remove") { + out.push({ line, oldNum }); + oldNum++; + } else { + out.push({ line, oldNum, newNum }); + oldNum++; + newNum++; + } + } + return out; +} + +// ---- Single diff line ---- +const DiffLineRow = memo( + ({ + item, + highlighted, + onAddComment, + }: { + item: NumberedLine; + highlighted?: string; + onAddComment?: (line: number) => void; + }) => { + const { line, oldNum, newNum } = item; + if (line.type === "header") return null; + if (line.type === "hunk") { + return ( +
+ {line.content} +
+ ); + } + const isAdd = line.type === "add"; + const isDel = line.type === "remove"; + const codeClass = cn( + "flex-1 whitespace-pre-wrap break-all pr-3", + isAdd && "text-emerald-100", + isDel && "text-rose-100", + !isAdd && !isDel && "text-foreground/80" + ); + const targetLine = isAdd ? newNum : isDel ? oldNum : newNum; + return ( +
+ {(isAdd || isDel) && ( + + )} + + {isAdd ? "" : oldNum ?? ""} + + + {isDel ? "" : newNum ?? ""} + {onAddComment && targetLine != null && ( + + )} + + {highlighted ? ( + + ) : ( + {line.content} + )} +
+ ); + } +); +DiffLineRow.displayName = "DiffLineRow"; + +// ---- Inline comment composer at a specific line ---- +function LineCommentComposer({ + path, + line, + onClose, +}: { + path: string; + line: number | null; + onClose: () => void; +}) { + const [body, setBody] = useState(""); + const inputRef = useRef(null); + useEffect(() => { + inputRef.current?.focus(); + }, []); + const submit = () => { + const trimmed = body.trim(); + if (!trimmed) { + onClose(); + return; + } + GitModel.getInstance().addComment(path, line, trimmed); + onClose(); + }; + return ( +
+