diff --git a/.github/ISSUE_TEMPLATE/upstream-sync-conflict.md b/.github/ISSUE_TEMPLATE/upstream-sync-conflict.md new file mode 100644 index 00000000000..5a61d71e995 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/upstream-sync-conflict.md @@ -0,0 +1,70 @@ +--- +name: Upstream Sync Conflict +about: Automatically created when upstream sync encounters conflicts +title: "[Upstream Sync] Merge conflict with {{ tag }}" +labels: upstream-sync, needs-manual-review +assignees: "" +--- + +## Upstream Sync Conflict Report + +**Trigger**: Upstream sync at {{ timestamp }} +**Upstream Tag**: {{ tag }} +**Upstream SHA**: {{ upstream_sha }} +**Integration SHA**: {{ integration_sha }} + +### Conflicting Files + +{{ conflict_list }} + +### Recommended Actions + +1. Checkout integration branch locally +2. Run: `git fetch origin && git merge origin/dev` +3. Resolve conflicts manually +4. Run validation: + ```bash + bun install + bun turbo typecheck + bun turbo test + ``` +5. Push resolved integration branch +6. Close this issue + +### Resolution Strategies + +| File Pattern | Resolution Strategy | +| ------------------------------- | ----------------------------------------------- | +| `bun.lock` | Regenerate from merged manifest: `bun install` | +| `*.md` (docs) | Accept upstream: `git checkout --theirs ` | +| `package.json` | Manual review required | +| `.github/*` (workflow configs) | Keep ours: `git checkout --ours ` | +| Shared code with custom changes | Manual review required | + +### Manual Sync Commands + +```bash +git fetch origin +git checkout integration +git merge origin/dev + +# Resolve conflicts... + +bun install +bun turbo typecheck +bun turbo test +bun turbo build + +git add . +git commit -m "sync: resolve conflicts with {{ tag }}" +git push origin integration +``` + +### Logs + +
+Merge output + +{{ merge_output }} + +
diff --git a/.github/last-synced-tag b/.github/last-synced-tag new file mode 100644 index 00000000000..0576e7b9f26 --- /dev/null +++ b/.github/last-synced-tag @@ -0,0 +1 @@ +v1.0.110 diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml index 815433f03d9..dc2c7f407e6 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -4,11 +4,13 @@ on: push: branches: - dev - - fix-snapshot-2 - - v0 concurrency: ${{ github.workflow }}-${{ github.ref }} +permissions: + contents: write + workflows: write + jobs: publish: runs-on: ubuntu-latest @@ -16,6 +18,7 @@ jobs: - uses: actions/checkout@v3 with: fetch-depth: 0 + token: ${{ secrets.PAT_TOKEN }} - run: git fetch --force --tags @@ -31,5 +34,5 @@ jobs: run: | ./script/publish.ts env: - GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/upstream-sync.yml b/.github/workflows/upstream-sync.yml new file mode 100644 index 00000000000..cf858d3f55b --- /dev/null +++ b/.github/workflows/upstream-sync.yml @@ -0,0 +1,352 @@ +name: Upstream Sync + +on: + schedule: + - cron: "*/15 * * * *" # Check for new releases every 15 minutes + workflow_dispatch: + inputs: + force_sync: + description: "Force sync even if no new release" + type: boolean + default: false + +concurrency: + group: upstream-sync + cancel-in-progress: false + +permissions: + contents: write + issues: write + +jobs: + check-release: + runs-on: ubuntu-latest + outputs: + new_release: ${{ steps.check.outputs.new_release }} + latest_tag: ${{ steps.check.outputs.latest_tag }} + latest_sha: ${{ steps.check.outputs.latest_sha }} + steps: + - name: Checkout integration branch + uses: actions/checkout@v4 + with: + ref: integration + fetch-depth: 0 + + - name: Add upstream remote + run: | + git remote add upstream https://github.com/sst/opencode.git 2>/dev/null || true + + - name: Check for new release + id: check + run: | + git fetch upstream --tags + + # Get latest upstream release tag (semver sorted) + LATEST_TAG=$(git ls-remote --tags --sort='-v:refname' upstream 'v*' \ + | sed 's|.*/||' | grep -E '^v[0-9]+' | head -1) + + echo "Latest upstream tag: $LATEST_TAG" + + # Resolve upstream tag to SHA + LATEST_SHA=$(git ls-remote upstream "refs/tags/$LATEST_TAG" | awk '{print $1}') + echo "Latest upstream SHA: $LATEST_SHA" + + # Get current dev branch SHA + DEV_SHA=$(git ls-remote origin refs/heads/dev | awk '{print $1}') + echo "Current dev SHA: $DEV_SHA" + + # Check if force sync is requested + FORCE_SYNC="${{ inputs.force_sync }}" + + if [ "$LATEST_SHA" != "$DEV_SHA" ] || [ "$FORCE_SYNC" = "true" ]; then + echo "New release detected or force sync requested: $LATEST_TAG ($LATEST_SHA)" + echo "new_release=true" >> $GITHUB_OUTPUT + echo "latest_tag=$LATEST_TAG" >> $GITHUB_OUTPUT + echo "latest_sha=$LATEST_SHA" >> $GITHUB_OUTPUT + else + echo "No new release detected" + echo "new_release=false" >> $GITHUB_OUTPUT + fi + + sync-dev: + needs: check-release + if: needs.check-release.outputs.new_release == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: dev + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Add upstream remote + run: | + git remote add upstream https://github.com/sst/opencode.git 2>/dev/null || true + + - name: Sync dev to upstream tag + run: | + git fetch upstream --tags + git reset --hard ${{ needs.check-release.outputs.latest_tag }} + git push origin dev --force + + merge-integration: + needs: [check-release, sync-dev] + runs-on: ubuntu-latest + outputs: + merge_status: ${{ steps.merge.outputs.status }} + conflict_files: ${{ steps.merge.outputs.conflict_files }} + steps: + - name: Checkout integration branch + uses: actions/checkout@v4 + with: + ref: integration + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Fetch dev branch + run: | + git fetch origin dev:dev + + - name: Attempt merge + id: merge + run: | + # Attempt merge with no-commit to detect conflicts first + if git merge dev --no-commit --no-ff 2>&1; then + echo "Merge successful, no conflicts" + # Check if there are changes to commit + if git diff --cached --quiet; then + echo "Already up to date, nothing to merge" + echo "status=up-to-date" >> $GITHUB_OUTPUT + else + git commit -m "sync: merge upstream ${{ needs.check-release.outputs.latest_tag }} into integration" + echo "status=success" >> $GITHUB_OUTPUT + fi + else + # Check for conflicts + CONFLICTS=$(git diff --name-only --diff-filter=U) + if [ -n "$CONFLICTS" ]; then + echo "Conflicts detected in: $CONFLICTS" + echo "status=conflict" >> $GITHUB_OUTPUT + echo "conflict_files<> $GITHUB_OUTPUT + echo "$CONFLICTS" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + # Attempt auto-resolution for known patterns + UNRESOLVED="" + for file in $CONFLICTS; do + case "$file" in + bun.lock) + echo "Auto-resolving bun.lock by regenerating..." + git checkout --theirs bun.lock 2>/dev/null || true + git add bun.lock + ;; + *.md) + echo "Auto-resolving $file (accepting upstream)..." + git checkout --theirs "$file" + git add "$file" + ;; + *) + UNRESOLVED="$UNRESOLVED $file" + ;; + esac + done + + # Check if all conflicts were resolved + REMAINING=$(git diff --name-only --diff-filter=U) + if [ -z "$REMAINING" ]; then + echo "All conflicts auto-resolved" + git commit -m "sync: merge upstream ${{ needs.check-release.outputs.latest_tag }} into integration (auto-resolved conflicts)" + echo "status=auto-resolved" >> $GITHUB_OUTPUT + else + echo "Unresolved conflicts remain: $REMAINING" + git merge --abort + echo "status=needs-manual" >> $GITHUB_OUTPUT + fi + else + echo "Merge failed for unknown reason" + git merge --abort 2>/dev/null || true + echo "status=failed" >> $GITHUB_OUTPUT + fi + fi + + - name: Push integration branch + if: steps.merge.outputs.status == 'success' || steps.merge.outputs.status == 'auto-resolved' + run: | + git push origin integration + + - name: Update last synced tag marker + if: steps.merge.outputs.status == 'success' || steps.merge.outputs.status == 'auto-resolved' || steps.merge.outputs.status == 'up-to-date' + run: | + echo "${{ needs.check-release.outputs.latest_tag }}" > .github/last-synced-tag + git add .github/last-synced-tag + git commit -m "sync: record last synced tag ${{ needs.check-release.outputs.latest_tag }}" || true + git push origin integration || true + + validate: + needs: [check-release, merge-integration] + if: needs.merge-integration.outputs.merge_status == 'success' || needs.merge-integration.outputs.merge_status == 'auto-resolved' || needs.merge-integration.outputs.merge_status == 'up-to-date' + runs-on: ubuntu-latest + outputs: + validation_status: ${{ steps.validate.outputs.status }} + validation_error: ${{ steps.validate.outputs.error }} + steps: + - name: Checkout integration branch + uses: actions/checkout@v4 + with: + ref: integration + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Run validation + id: validate + run: | + set +e + + echo "Running typecheck..." + bun turbo typecheck + if [ $? -ne 0 ]; then + echo "status=failed" >> $GITHUB_OUTPUT + echo "error=typecheck failed" >> $GITHUB_OUTPUT + exit 1 + fi + + echo "Running tests..." + bun turbo test + if [ $? -ne 0 ]; then + echo "status=failed" >> $GITHUB_OUTPUT + echo "error=tests failed" >> $GITHUB_OUTPUT + exit 1 + fi + + echo "Running build..." + bun turbo build + if [ $? -ne 0 ]; then + echo "status=failed" >> $GITHUB_OUTPUT + echo "error=build failed" >> $GITHUB_OUTPUT + exit 1 + fi + + echo "status=success" >> $GITHUB_OUTPUT + + create-issue: + needs: [check-release, merge-integration, validate] + if: | + always() && + (needs.merge-integration.outputs.merge_status == 'needs-manual' || + needs.merge-integration.outputs.merge_status == 'failed' || + needs.validate.outputs.validation_status == 'failed') + runs-on: ubuntu-latest + steps: + - name: Checkout integration branch + uses: actions/checkout@v4 + with: + ref: integration + + - name: Create conflict issue + if: needs.merge-integration.outputs.merge_status == 'needs-manual' + uses: actions/github-script@v7 + with: + script: | + const conflictFiles = `${{ needs.merge-integration.outputs.conflict_files }}`; + const latestTag = `${{ needs.check-release.outputs.latest_tag }}`; + const latestSha = `${{ needs.check-release.outputs.latest_sha }}`; + + const body = `## Upstream Sync Conflict Report + + **Trigger**: Upstream sync at ${new Date().toISOString()} + **Upstream Tag**: ${latestTag} + **Upstream SHA**: ${latestSha} + + ### Conflicting Files + + \`\`\` + ${conflictFiles} + \`\`\` + + ### Recommended Actions + + 1. Checkout integration branch locally + 2. Run: \`git fetch origin && git merge origin/dev\` + 3. Resolve conflicts manually + 4. Run validation: \`bun install && bun turbo typecheck && bun turbo test\` + 5. Push resolved integration branch + 6. Close this issue + + ### Manual Sync Commands + + \`\`\`bash + git fetch origin + git checkout integration + git merge origin/dev + # Resolve conflicts... + bun install + bun turbo typecheck + bun turbo test + git push origin integration + \`\`\` + `; + + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `[Upstream Sync] Merge conflict with ${latestTag}`, + body: body, + labels: ['upstream-sync', 'needs-manual-review'] + }); + + - name: Create validation failure issue + if: needs.validate.outputs.validation_status == 'failed' + uses: actions/github-script@v7 + with: + script: | + const latestTag = `${{ needs.check-release.outputs.latest_tag }}`; + const error = `${{ needs.validate.outputs.validation_error }}`; + + const body = `## Upstream Sync Validation Failure + + **Trigger**: Upstream sync at ${new Date().toISOString()} + **Upstream Tag**: ${latestTag} + **Error**: ${error} + + ### What happened + + The upstream sync merged successfully, but post-merge validation failed. + + ### Recommended Actions + + 1. Check the [workflow run](${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}) for detailed logs + 2. Checkout integration branch locally + 3. Run the failing validation step locally to debug + 4. Fix any issues and push to integration + + ### Validation Commands + + \`\`\`bash + git fetch origin + git checkout integration + bun install + bun turbo typecheck + bun turbo test + bun turbo build + \`\`\` + `; + + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `[Upstream Sync] Validation failed after merging ${latestTag}`, + body: body, + labels: ['upstream-sync', 'needs-manual-review'] + }); diff --git a/.opencode/agent/docs.md b/.opencode/agent/docs.md deleted file mode 100644 index be7c203668e..00000000000 --- a/.opencode/agent/docs.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -description: ALWAYS use this when writing docs ---- - -You are an expert technical documentation writer - -You are not verbose - -The title of the page should be a word or a 2-3 word phrase - -The description should be one short line, should not start with "The", should -avoid repeating the title of the page, should be 5-10 words long - -Chunks of text should not be more than 2 sentences long - -Each section is separated by a divider of 3 dashes - -The section titles are short with only the first letter of the word capitalized - -The section titles are in the imperative mood - -The section titles should not repeat the term used in the page title, for -example, if the page title is "Models", avoid using a section title like "Add -new models". This might be unavoidable in some cases, but try to avoid it. - -Check out the /packages/web/src/content/docs/docs/index.mdx as an example. - -For JS or TS code snippets remove trailing semicolons and any trailing commas -that might not be needed. - -If you are making a commit prefix the commit message with `docs:` diff --git a/.opencode/agent/git-committer.md b/.opencode/agent/git-committer.md deleted file mode 100644 index 49c3e3de19f..00000000000 --- a/.opencode/agent/git-committer.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -description: Use this agent when you are asked to commit and push code changes to a git repository. -mode: subagent ---- - -You commit and push to git - -Commit messages should be brief since they are used to generate release notes. - -Messages should say WHY the change was made and not WHAT was changed. diff --git a/.opencode/command/commit.md b/.opencode/command/commit.md deleted file mode 100644 index 2e3d759b654..00000000000 --- a/.opencode/command/commit.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -description: Git commit and push ---- - -commit and push - -make sure it includes a prefix like -docs: -tui: -core: -ci: -ignore: -wip: - -For anything in the packages/web use the docs: prefix. - -For anything in the packages/app use the ignore: prefix. - -prefer to explain WHY something was done from an end user perspective instead of -WHAT was done. - -do not do generic messages like "improved agent experience" be very specific -about what user facing changes were made diff --git a/.opencode/command/hello.md b/.opencode/command/hello.md deleted file mode 100644 index 003bc4a760b..00000000000 --- a/.opencode/command/hello.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -description: hello world iaosd ioasjdoiasjd oisadjoisajd osiajd oisaj dosaij dsoajsajdaijdoisa jdoias jdoias jdoia jois jo jdois jdoias jdoias j djoasdj ---- - -hey there $ARGUMENTS - -!`ls` -check out @README.md diff --git a/.opencode/command/issues.md b/.opencode/command/issues.md deleted file mode 100644 index 793dce6517f..00000000000 --- a/.opencode/command/issues.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -description: "Find issue(s) on github" -model: opencode/claude-haiku-4-5 ---- - -Search through existing issues in sst/opencode using the gh cli to find issues matching this query: - -$ARGUMENTS - -Consider: - -1. Similar titles or descriptions -2. Same error messages or symptoms -3. Related functionality or components -4. Similar feature requests - -Please list any matching issues with: - -- Issue number and title -- Brief explanation of why it matches the query -- Link to the issue - -If no clear matches are found, say so. diff --git a/.opencode/command/spellcheck.md b/.opencode/command/spellcheck.md deleted file mode 100644 index afa1970b779..00000000000 --- a/.opencode/command/spellcheck.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -description: Spellcheck all markdown file changes ---- - -Look at all the unstaged changes to markdown (.md, .mdx) files, pull out the lines that have changed, and check for spelling and grammar errors. diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc deleted file mode 100644 index dd5a4c750fb..00000000000 --- a/.opencode/opencode.jsonc +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "plugin": ["opencode-openai-codex-auth"], - // "enterprise": { - // "url": "http://localhost:3000", - // }, - "provider": { - "opencode": { - "options": { - // "baseURL": "http://localhost:8080", - }, - }, - }, -} diff --git a/.opencode/themes/mytheme.json b/.opencode/themes/mytheme.json deleted file mode 100644 index e444de807c6..00000000000 --- a/.opencode/themes/mytheme.json +++ /dev/null @@ -1,223 +0,0 @@ -{ - "$schema": "https://opencode.ai/theme.json", - "defs": { - "nord0": "#2E3440", - "nord1": "#3B4252", - "nord2": "#434C5E", - "nord3": "#4C566A", - "nord4": "#D8DEE9", - "nord5": "#E5E9F0", - "nord6": "#ECEFF4", - "nord7": "#8FBCBB", - "nord8": "#88C0D0", - "nord9": "#81A1C1", - "nord10": "#5E81AC", - "nord11": "#BF616A", - "nord12": "#D08770", - "nord13": "#EBCB8B", - "nord14": "#A3BE8C", - "nord15": "#B48EAD" - }, - "theme": { - "primary": { - "dark": "nord8", - "light": "nord10" - }, - "secondary": { - "dark": "nord9", - "light": "nord9" - }, - "accent": { - "dark": "nord7", - "light": "nord7" - }, - "error": { - "dark": "nord11", - "light": "nord11" - }, - "warning": { - "dark": "nord12", - "light": "nord12" - }, - "success": { - "dark": "nord14", - "light": "nord14" - }, - "info": { - "dark": "nord8", - "light": "nord10" - }, - "text": { - "dark": "nord4", - "light": "nord0" - }, - "textMuted": { - "dark": "nord3", - "light": "nord1" - }, - "background": { - "dark": "nord0", - "light": "nord6" - }, - "backgroundPanel": { - "dark": "nord1", - "light": "nord5" - }, - "backgroundElement": { - "dark": "nord1", - "light": "nord4" - }, - "border": { - "dark": "nord2", - "light": "nord3" - }, - "borderActive": { - "dark": "nord3", - "light": "nord2" - }, - "borderSubtle": { - "dark": "nord2", - "light": "nord3" - }, - "diffAdded": { - "dark": "nord14", - "light": "nord14" - }, - "diffRemoved": { - "dark": "nord11", - "light": "nord11" - }, - "diffContext": { - "dark": "nord3", - "light": "nord3" - }, - "diffHunkHeader": { - "dark": "nord3", - "light": "nord3" - }, - "diffHighlightAdded": { - "dark": "nord14", - "light": "nord14" - }, - "diffHighlightRemoved": { - "dark": "nord11", - "light": "nord11" - }, - "diffAddedBg": { - "dark": "#3B4252", - "light": "#E5E9F0" - }, - "diffRemovedBg": { - "dark": "#3B4252", - "light": "#E5E9F0" - }, - "diffContextBg": { - "dark": "nord1", - "light": "nord5" - }, - "diffLineNumber": { - "dark": "nord2", - "light": "nord4" - }, - "diffAddedLineNumberBg": { - "dark": "#3B4252", - "light": "#E5E9F0" - }, - "diffRemovedLineNumberBg": { - "dark": "#3B4252", - "light": "#E5E9F0" - }, - "markdownText": { - "dark": "nord4", - "light": "nord0" - }, - "markdownHeading": { - "dark": "nord8", - "light": "nord10" - }, - "markdownLink": { - "dark": "nord9", - "light": "nord9" - }, - "markdownLinkText": { - "dark": "nord7", - "light": "nord7" - }, - "markdownCode": { - "dark": "nord14", - "light": "nord14" - }, - "markdownBlockQuote": { - "dark": "nord3", - "light": "nord3" - }, - "markdownEmph": { - "dark": "nord12", - "light": "nord12" - }, - "markdownStrong": { - "dark": "nord13", - "light": "nord13" - }, - "markdownHorizontalRule": { - "dark": "nord3", - "light": "nord3" - }, - "markdownListItem": { - "dark": "nord8", - "light": "nord10" - }, - "markdownListEnumeration": { - "dark": "nord7", - "light": "nord7" - }, - "markdownImage": { - "dark": "nord9", - "light": "nord9" - }, - "markdownImageText": { - "dark": "nord7", - "light": "nord7" - }, - "markdownCodeBlock": { - "dark": "nord4", - "light": "nord0" - }, - "syntaxComment": { - "dark": "nord3", - "light": "nord3" - }, - "syntaxKeyword": { - "dark": "nord9", - "light": "nord9" - }, - "syntaxFunction": { - "dark": "nord8", - "light": "nord8" - }, - "syntaxVariable": { - "dark": "nord7", - "light": "nord7" - }, - "syntaxString": { - "dark": "nord14", - "light": "nord14" - }, - "syntaxNumber": { - "dark": "nord15", - "light": "nord15" - }, - "syntaxType": { - "dark": "nord7", - "light": "nord7" - }, - "syntaxOperator": { - "dark": "nord9", - "light": "nord9" - }, - "syntaxPunctuation": { - "dark": "nord4", - "light": "nord0" - } - } -} diff --git a/UPSTREAM-SYNC-PLAN.md b/UPSTREAM-SYNC-PLAN.md new file mode 100644 index 00000000000..19d5784ad94 --- /dev/null +++ b/UPSTREAM-SYNC-PLAN.md @@ -0,0 +1,400 @@ +# Automated Upstream Sync Pipeline for OpenCode Fork + +## Overview + +Implement a GitHub Actions workflow that automatically syncs the fork's `dev` branch with upstream `sst/opencode` and merges changes into the `integration` branch while preserving custom features. + +## Branch Architecture + +``` +integration (DEFAULT) ← Your working branch, custom features, workflow configs + ↑ + │ merge + │ + dev (MIRROR) ← Read-only mirror of upstream release tags + ↑ + │ hard reset + │ +upstream/tags ← sst/opencode release tags +``` + +**Key Change**: `integration` becomes the default branch because: + +- Workflow files must live on the default branch for scheduled triggers +- `dev` will be hard reset, which would wipe any workflow configs +- `integration` is where actual development happens anyway + +--- + +## Workflow Architecture + +### Trigger Mechanism: Release Tag Detection + +Since we're syncing on upstream **releases** (not every dev commit), the workflow will: + +1. **Scheduled polling** to check for new tags on upstream +2. Poll every 15 minutes: `*/15 * * * *` +3. Compare latest upstream tag against the current `origin/dev` commit (or a marker stored on `integration` / repo variable) +4. Only proceed when a new tag is detected (e.g., `v1.0.111`) + +### Workflow: `.github/workflows/upstream-sync.yml` + +```yaml +name: Upstream Sync +on: + schedule: + - cron: "*/15 * * * *" # Check for new releases every 15 minutes + workflow_dispatch: # Manual trigger option + inputs: + force_sync: + description: "Force sync even if no new release" + type: boolean + default: false +``` + +### Tag Detection Logic + +```bash +# Fetch upstream tags directly from upstream remote +git fetch upstream --tags + +# Get latest upstream release tag (semver sorted, from upstream only) +LATEST_TAG=$(git ls-remote --tags --sort='-v:refname' upstream 'v*' \ + | sed 's|.*/||' | grep -E '^v[0-9]+' | head -1) + +# Resolve upstream tag to SHA and compare to current origin/dev +LATEST_SHA=$(git ls-remote upstream "refs/tags/$LATEST_TAG" | awk '{print $1}') +DEV_SHA=$(git ls-remote origin refs/heads/dev | awk '{print $1}') + +if [ "$LATEST_SHA" != "$DEV_SHA" ]; then + echo "New release detected: $LATEST_TAG ($LATEST_SHA)" + # Proceed with sync (and optionally update .github/last-synced-tag on integration or repo variable) +else + echo "No new release; exit" + exit 0 +fi +``` + +--- + +## Phase 1: Dev Branch Sync (Mirror to Release Tag) + +### Process + +```bash +# Fetch upstream with tags +git fetch upstream --tags + +# Ensure upstream remote exists +git remote add upstream https://github.com/sst/opencode.git 2>/dev/null || true + +# Checkout dev and reset to the release tag +git checkout dev +git reset --hard $LATEST_TAG +git push origin dev --force + +# Optional: store marker on integration branch or repo variable (not on dev) +echo "$LATEST_TAG" > .github/last-synced-tag +git checkout integration +git add .github/last-synced-tag +git commit -m "sync: record last synced tag $LATEST_TAG" || true +git push origin integration || true +``` + +### Key Considerations + +- Uses `--force` push since dev is a true mirror of upstream releases +- No merge commits, no local history preserved +- Dev branch has no protection (mirror-only branch) +- Syncs to the tagged release commit, not arbitrary dev commits + +--- + +## Phase 2: Integration Branch Merge + +### Process + +```bash +git checkout integration +git merge dev --no-edit +``` + +### Conflict Detection Strategy + +1. **Attempt merge with `--no-commit` first** to detect conflicts +2. If conflicts detected: + - Identify conflicting files + - Apply resolution strategies per file type + - If unresolvable, create GitHub Issue and abort + +### Lock File Resolution (bun.lock) + +```bash +# Regenerate lock from merged package.json (preferred) +bun install --frozen-lockfile || bun install +git add bun.lock + +# Fallback if regeneration fails: accept upstream lock to unblock, then create an issue +# git checkout --theirs bun.lock +# git add bun.lock +``` + +### Other Conflict Patterns + +| File Pattern | Resolution Strategy | +| ------------------------------- | ----------------------------------------------------------- | +| `bun.lock` | Regenerate from merged manifest (fallback: accept upstream) | +| `*.md` (docs) | Accept upstream | +| `package.json` | Manual review required | +| Custom feature files | Keep ours (integration) | +| Shared code with custom changes | Manual review required | + +--- + +## Phase 3: Post-Merge Validation + +### Steps + +1. Run `bun install` (ensures dependencies are correct) +2. Run `bun turbo typecheck` (type safety) +3. Run `bun turbo test` (unit tests) +4. Verify build: `bun turbo build` + +### Failure Handling + +- If validation fails, create GitHub Issue with: + - Failed step details + - Error logs + - Commit SHAs involved +- Do NOT push broken integration branch; abort push and keep `integration` untouched + +--- + +## Phase 4: Notifications + +### GitHub Issue Template for Conflicts + +```markdown +## Upstream Sync Conflict Report + +**Trigger**: Upstream sync at {{ timestamp }} +**Upstream SHA**: {{ upstream_sha }} +**Integration SHA**: {{ integration_sha }} + +### Conflicting Files + +{{ conflict_list }} + +### Recommended Actions + +1. Checkout integration branch locally +2. Run: `git merge origin/dev` +3. Resolve conflicts manually +4. Push resolved integration branch + +### Logs + +
+Merge output +{{ merge_output }} +
+``` + +### Issue Labels + +- `upstream-sync` +- `needs-manual-review` +- Auto-assign to repository maintainers + +--- + +## Implementation Files + +### 1. Main Workflow: `.github/workflows/upstream-sync.yml` + +Creates the sync pipeline with: + +- Scheduled trigger (15 min) +- Manual dispatch option +- Dev mirror sync job +- Integration merge job +- Validation job +- Issue creation on failure +- Concurrency guard to prevent overlapping runs +- Permissions: `contents: write`, `issues: write`, token allowed to force-push `dev` + +### 2. Conflict Detection Script: `script/sync/detect-conflicts.ts` + +TypeScript script that: + +- Attempts merge dry-run +- Parses conflict output +- Categorizes conflicts by file type +- Returns resolution recommendations + +### 3. Issue Template: `.github/ISSUE_TEMPLATE/upstream-sync-conflict.md` + +Pre-formatted issue template for conflict reports + +--- + +## Branch Protection Configuration + +### `dev` Branch (Mirror) + +- **No protection** - this is a mirror-only branch +- Force pushes allowed (needed for sync workflow) +- No PRs required +- Not the default branch + +### `integration` Branch (Default) + +- This is the **default branch** where: + - Workflow files live + - Custom features are developed + - PRs are merged +- Optional protection rules: + - Require status checks (`typecheck`, `test`, `build`) + - Require PR for manual changes +- Sync workflow pushes directly (automated merges) + +--- + +## Workflow Diagram + +``` +┌─────────────────┐ +│ Schedule/Manual │ +│ Trigger │ +└────────┬────────┘ + │ + v +┌─────────────────┐ +│ Fetch Upstream │ +│ Tags & Check │ +│ for new release│ +└────────┬────────┘ + │ + ┌────┴────┐ + │New tag? │ + └────┬────┘ + No │ Yes + │ │ │ + v │ v + [End] │┌─────────────────┐ + ││ Reset dev to │ + ││ release tag │ + ││ (force push) │ + │└────────┬────────┘ + │ + v +┌─────────────────┐ +│ Merge dev into │ +│ integration │ +└────────┬────────┘ + │ + ┌────┴────┐ + │Conflicts│ + │ ? │ + └────┬────┘ + No │ Yes + │ │ │ + v │ v +┌───────┐│┌─────────────┐ +│ Run │││ Auto-resolve│ +│ Tests │││ (bun.lock) │ +└───┬───┘│└──────┬──────┘ + │ │ │ + │ │ ┌────┴────┐ + │ │ │Resolved?│ + │ │ └────┬────┘ + │ │ Yes │ No + │ │ │ │ │ + │ │ v │ v + │ │┌─────┐│┌──────────┐ + │ ││Tests│││ Create │ + │ │└──┬──┘││ Issue │ + │ │ │ │└──────────┘ + v v v │ +┌─────────────┐ │ +│Push updated │ │ +│ integration │ │ +└─────────────┘ │ + │ + ┌───────┘ + v + [Workflow End] +``` + +--- + +## Critical Files to Create/Modify + +1. **Create**: `.github/workflows/upstream-sync.yml` - Main workflow (on `integration` branch) +2. **Create**: `script/sync/detect-conflicts.ts` - Conflict detection helper +3. **Create**: `.github/last-synced-tag` - Tracks last synced upstream release tag +4. **Create**: `.github/ISSUE_TEMPLATE/upstream-sync-conflict.md` - Conflict issue template +5. **Configure**: Repository secret/variable for upstream token (if different) and ensure `GITHUB_TOKEN` can force-push `dev` + +## Manual Setup Steps (GitHub UI) + +1. **Change default branch**: Settings → Branches → Change default from `dev` to `integration` +2. **Remove branch protection from `dev`**: Settings → Branches → Delete any rules for `dev` +3. **Add branch protection to `integration`** (optional): Require status checks for PRs + +--- + +## Success Criteria Validation + +| Criteria | Implementation | +| ---------------------------------- | ------------------------------------------------------------------------ | +| Trigger within 15 min of release | `cron: '*/15 * * * *'` polls for new tags | +| dev syncs with zero manual steps | Hard reset to tag + force push | +| integration retains custom changes | Merge strategy with "ours" for feature files | +| Conflicts flagged within 30 min | GitHub Issue created immediately on detection | +| Audit log maintained | GitHub Actions run history + commit messages + `.github/last-synced-tag` | + +--- + +## Risks and Mitigations + +| Risk | Mitigation | +| ------------------------------------------ | -------------------------------------------------------------- | +| Force push to dev loses unintended changes | Dev is designated mirror-only; all work happens on integration | +| Frequent conflicts due to active upstream | Lock file auto-resolution; categorized conflict handling | +| Test failures block sync | Separate validation job; clear failure reporting | +| GitHub Actions rate limits | 15-min schedule is conservative; skip if no changes | + +--- + +## Manual Runbook + +### Resolving Conflicts Manually + +1. Check the GitHub Issue created by the workflow +2. Clone or pull latest: + ```bash + git fetch origin + git checkout integration + git merge origin/dev + ``` +3. Resolve conflicts per the guidance in the issue +4. Run validation locally: + ```bash + bun install + bun turbo typecheck + bun turbo test + ``` +5. Push resolved branch: + ```bash + git push origin integration + ``` +6. Close the GitHub Issue + +### Force Re-sync + +Trigger manual workflow dispatch from GitHub Actions UI or: + +```bash +gh workflow run upstream-sync.yml +``` diff --git a/bun.lock b/bun.lock index b81a80cfe71..634616a4222 100644 --- a/bun.lock +++ b/bun.lock @@ -764,8 +764,6 @@ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q=="], "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ=="], @@ -850,8 +848,6 @@ "@internationalized/number": ["@internationalized/number@3.6.5", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g=="], - "@ioredis/commands": ["@ioredis/commands@1.4.0", "", {}, "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ=="], - "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="], @@ -1842,7 +1838,7 @@ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.8.30", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.8.31", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw=="], "bcp-47": ["bcp-47@2.1.0", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w=="], @@ -1866,7 +1862,7 @@ "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], - "bowser": ["bowser@2.12.1", "", {}, "sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw=="], + "bowser": ["bowser@2.13.0", "", {}, "sha512-yHAbSRuT6LTeKi6k2aS40csueHqgAsFEgmrOsfRyFpJnFv5O2hl9FYmWEUZ97gZ/dG17U4IQQcTx4YAFYPuWRQ=="], "boxen": ["boxen@8.0.1", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^8.0.0", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "string-width": "^7.2.0", "type-fest": "^4.21.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0" } }, "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw=="], @@ -1916,7 +1912,7 @@ "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], - "caniuse-lite": ["caniuse-lite@1.0.30001756", "", {}, "sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A=="], + "caniuse-lite": ["caniuse-lite@1.0.30001757", "", {}, "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -1964,8 +1960,6 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], - "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], - "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], @@ -2124,7 +2118,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "electron-to-chromium": ["electron-to-chromium@1.5.259", "", {}, "sha512-I+oLXgpEJzD6Cwuwt1gYjxsDmu/S/Kd41mmLA3O+/uH2pFRO/DvOjUyGozL8j3KeLV6WyZ7ssPwELMsXCcsJAQ=="], + "electron-to-chromium": ["electron-to-chromium@1.5.260", "", {}, "sha512-ov8rBoOBhVawpzdre+Cmz4FB+y66Eqrk6Gwqd8NGxuhv99GQ8XqMAr351KEkOt7gukXWDg6gJWEMKgL2RLMPtA=="], "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], @@ -2478,8 +2472,6 @@ "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], - "ioredis": ["ioredis@5.8.2", "", { "dependencies": { "@ioredis/commands": "1.4.0", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q=="], - "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], @@ -2668,12 +2660,8 @@ "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], - "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], - "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], - "lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="], - "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], "lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="], @@ -2748,7 +2736,7 @@ "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], - "mdast-util-to-hast": ["mdast-util-to-hast@13.2.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA=="], + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], @@ -3164,10 +3152,6 @@ "recma-stringify": ["recma-stringify@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-to-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g=="], - "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], - - "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], - "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], "regex": ["regex@6.0.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA=="], @@ -3376,8 +3360,6 @@ "stage-js": ["stage-js@1.0.0-alpha.17", "", {}, "sha512-AzlMO+t51v6cFvKZ+Oe9DJnL1OXEH5s9bEy6di5aOrUpcP7PCzI/wIeXF0u3zg0L89gwnceoKxrLId0ZpYnNXw=="], - "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], - "statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], @@ -3766,6 +3748,8 @@ "@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + "@aws-crypto/sha256-browser/@aws-sdk/types": ["@aws-sdk/types@3.775.0", "", { "dependencies": { "@smithy/types": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-ZoGKwa4C9fC9Av6bdfqcW6Ix5ot05F/S4VxWR2nHuMv7hzfmAjTOcUiWT7UR4hM/U0whf84VhDtXN/DWAk52KA=="], + "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], "@aws-crypto/sha256-js/@aws-sdk/types": ["@aws-sdk/types@3.775.0", "", { "dependencies": { "@smithy/types": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-ZoGKwa4C9fC9Av6bdfqcW6Ix5ot05F/S4VxWR2nHuMv7hzfmAjTOcUiWT7UR4hM/U0whf84VhDtXN/DWAk52KA=="], @@ -3938,8 +3922,6 @@ "@slack/web-api/p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ=="], - "@solidjs/start/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - "@solidjs/start/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], "@solidjs/start/shiki": ["shiki@1.29.2", "", { "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/langs": "1.29.2", "@shikijs/themes": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg=="], @@ -3994,6 +3976,8 @@ "boxen/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "c12/ohash": ["ohash@1.1.6", "", {}, "sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg=="], "c12/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], @@ -4364,7 +4348,7 @@ "@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - "@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="], + "@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.1", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw=="], "@modelcontextprotocol/sdk/express/content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], @@ -4450,56 +4434,6 @@ "@slack/web-api/p-queue/p-timeout": ["p-timeout@3.2.0", "", { "dependencies": { "p-finally": "^1.0.0" } }, "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg=="], - "@solidjs/start/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], - - "@solidjs/start/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - - "@solidjs/start/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - - "@solidjs/start/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - - "@solidjs/start/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - - "@solidjs/start/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - - "@solidjs/start/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - - "@solidjs/start/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - - "@solidjs/start/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - - "@solidjs/start/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - - "@solidjs/start/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - - "@solidjs/start/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - - "@solidjs/start/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - - "@solidjs/start/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - - "@solidjs/start/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - - "@solidjs/start/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - - "@solidjs/start/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - - "@solidjs/start/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - - "@solidjs/start/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - - "@solidjs/start/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - - "@solidjs/start/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - - "@solidjs/start/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - - "@solidjs/start/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - - "@solidjs/start/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - - "@solidjs/start/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - "@solidjs/start/shiki/@shikijs/core": ["@shikijs/core@1.29.2", "", { "dependencies": { "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ=="], "@solidjs/start/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "oniguruma-to-es": "^2.2.0" } }, "sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A=="], @@ -4544,6 +4478,8 @@ "babel-plugin-module-resolver/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "bl/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -4662,8 +4598,6 @@ "type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "vitest/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -4754,8 +4688,6 @@ "@modelcontextprotocol/sdk/express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "@modelcontextprotocol/sdk/express/body-parser/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - "@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], "@modelcontextprotocol/sdk/raw-body/http-errors/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], @@ -4782,7 +4714,7 @@ "opencontrol/@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - "opencontrol/@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="], + "opencontrol/@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.1", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw=="], "opencontrol/@modelcontextprotocol/sdk/express/content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], @@ -4818,56 +4750,6 @@ "tw-to-css/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], - "vitest/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], - - "vitest/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - - "vitest/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - - "vitest/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - - "vitest/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - - "vitest/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - - "vitest/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - - "vitest/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - - "vitest/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - - "vitest/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - - "vitest/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - - "vitest/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - - "vitest/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - - "vitest/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - - "vitest/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - - "vitest/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - - "vitest/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - - "vitest/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - - "vitest/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - - "vitest/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - - "vitest/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - - "vitest/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - - "vitest/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - - "vitest/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - - "vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - "@aws-sdk/client-sts/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.782.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.775.0", "@aws-sdk/middleware-host-header": "3.775.0", "@aws-sdk/middleware-logger": "3.775.0", "@aws-sdk/middleware-recursion-detection": "3.775.0", "@aws-sdk/middleware-user-agent": "3.782.0", "@aws-sdk/region-config-resolver": "3.775.0", "@aws-sdk/types": "3.775.0", "@aws-sdk/util-endpoints": "3.782.0", "@aws-sdk/util-user-agent-browser": "3.775.0", "@aws-sdk/util-user-agent-node": "3.782.0", "@smithy/config-resolver": "^4.1.0", "@smithy/core": "^3.2.0", "@smithy/fetch-http-handler": "^5.0.2", "@smithy/hash-node": "^4.0.2", "@smithy/invalid-dependency": "^4.0.2", "@smithy/middleware-content-length": "^4.0.2", "@smithy/middleware-endpoint": "^4.1.0", "@smithy/middleware-retry": "^4.1.0", "@smithy/middleware-serde": "^4.0.3", "@smithy/middleware-stack": "^4.0.2", "@smithy/node-config-provider": "^4.0.2", "@smithy/node-http-handler": "^4.0.4", "@smithy/protocol-http": "^5.1.0", "@smithy/smithy-client": "^4.2.0", "@smithy/types": "^4.2.0", "@smithy/url-parser": "^4.0.2", "@smithy/util-base64": "^4.0.0", "@smithy/util-body-length-browser": "^4.0.0", "@smithy/util-body-length-node": "^4.0.0", "@smithy/util-defaults-mode-browser": "^4.0.8", "@smithy/util-defaults-mode-node": "^4.0.8", "@smithy/util-endpoints": "^3.0.2", "@smithy/util-middleware": "^4.0.2", "@smithy/util-retry": "^4.0.2", "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" } }, "sha512-QOYC8q7luzHFXrP0xYAqBctoPkynjfV0r9dqntFu4/IWMTyC1vlo1UTxFAjIPyclYw92XJyEkVCVg9v/nQnsUA=="], "@jsx-email/cli/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -4878,8 +4760,6 @@ "opencontrol/@modelcontextprotocol/sdk/express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "opencontrol/@modelcontextprotocol/sdk/express/body-parser/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - "opencontrol/@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], "opencontrol/@modelcontextprotocol/sdk/raw-body/http-errors/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], diff --git a/nix/bundle.ts b/nix/bundle.ts index effb1dff7cc..460865971dc 100644 --- a/nix/bundle.ts +++ b/nix/bundle.ts @@ -9,6 +9,7 @@ const parser = fs.realpathSync(path.join(dir, "node_modules/@opentui/core/parser const worker = "./src/cli/cmd/tui/worker.ts" const version = process.env.OPENCODE_VERSION ?? "local" const channel = process.env.OPENCODE_CHANNEL ?? "local" +const base = process.env.OPENCODE_BASE_VERSION ?? version fs.rmSync(path.join(dir, "dist"), { recursive: true, force: true }) @@ -22,6 +23,7 @@ const result = await Bun.build({ external: ["@opentui/core"], define: { OPENCODE_VERSION: `'${version}'`, + OPENCODE_BASE_VERSION: `'${base}'`, OPENCODE_CHANNEL: `'${channel}'`, // Leave undefined so runtime picks bundled/dist worker or fallback in code. OPENCODE_WORKER_PATH: "undefined", diff --git a/nix/hashes.json b/nix/hashes.json index d764ca59b60..d7e61690679 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,3 +1,3 @@ { - "nodeModules": "sha256-cieNNPXZd4Bg9bZtRq2H8L99e24U8p5d+d76SE7SeJc=" + "nodeModules": "sha256-RVRgsKlWFBNFUoNITbDrq60kZhrSwgGKyi9YsF7o94A=" } diff --git a/nix/scripts/bun-build.ts b/nix/scripts/bun-build.ts index a227081639d..4b54f49bdf2 100644 --- a/nix/scripts/bun-build.ts +++ b/nix/scripts/bun-build.ts @@ -4,6 +4,8 @@ import fs from "fs" const version = "@VERSION@" const pkg = path.join(process.cwd(), "packages/opencode") +const pkgjson = JSON.parse(fs.readFileSync(path.join(pkg, "package.json"), "utf8")) as { version?: string } +const base = pkgjson.version ?? version const parser = fs.realpathSync(path.join(pkg, "./node_modules/@opentui/core/parser.worker.js")) const worker = "./src/cli/cmd/tui/worker.ts" const target = process.env["BUN_COMPILE_TARGET"] @@ -54,6 +56,7 @@ const result = await Bun.build({ entrypoints: ["./src/index.ts", parser, worker], define: { OPENCODE_VERSION: `'@VERSION@'`, + OPENCODE_BASE_VERSION: `'${base}'`, OTUI_TREE_SITTER_WORKER_PATH: "/$bunfs/root/" + path.relative(pkg, parser).replace(/\\/g, "/"), OPENCODE_CHANNEL: "'latest'", }, diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 98c332e3226..bc2d9e972c0 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -118,6 +118,7 @@ for (const item of targets) { entrypoints: ["./src/index.ts", parserWorker, workerPath], define: { OPENCODE_VERSION: `'${Script.version}'`, + OPENCODE_BASE_VERSION: `'${pkg.version}'`, OTUI_TREE_SITTER_WORKER_PATH: "/$bunfs/root/" + path.relative(dir, parserWorker).replaceAll("\\", "/"), OPENCODE_WORKER_PATH: workerPath, OPENCODE_CHANNEL: `'${Script.channel}'`, diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 740f67b7e04..1fad4b65862 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -5,6 +5,7 @@ import { generateObject, type ModelMessage } from "ai" import PROMPT_GENERATE from "./generate.txt" import { SystemPrompt } from "../session/system" import { Instance } from "../project/instance" +import { State } from "../project/state" import { mergeDeep } from "remeda" export namespace Agent { @@ -39,145 +40,149 @@ export namespace Agent { }) export type Info = z.infer - const state = Instance.state(async () => { - const cfg = await Config.get() - const defaultTools = cfg.tools ?? {} - const defaultPermission: Info["permission"] = { - edit: "allow", - bash: { - "*": "allow", - }, - webfetch: "allow", - doom_loop: "ask", - external_directory: "ask", - } - const agentPermission = mergeAgentPermissions(defaultPermission, cfg.permission ?? {}) - - const planPermission = mergeAgentPermissions( - { - edit: "deny", + const state = State.register( + "agent", + () => Instance.directory, + async () => { + const cfg = await Config.get() + const defaultTools = cfg.tools ?? {} + const defaultPermission: Info["permission"] = { + edit: "allow", bash: { - "cut*": "allow", - "diff*": "allow", - "du*": "allow", - "file *": "allow", - "find * -delete*": "ask", - "find * -exec*": "ask", - "find * -fprint*": "ask", - "find * -fls*": "ask", - "find * -fprintf*": "ask", - "find * -ok*": "ask", - "find *": "allow", - "git diff*": "allow", - "git log*": "allow", - "git show*": "allow", - "git status*": "allow", - "git branch": "allow", - "git branch -v": "allow", - "grep*": "allow", - "head*": "allow", - "less*": "allow", - "ls*": "allow", - "more*": "allow", - "pwd*": "allow", - "rg*": "allow", - "sort --output=*": "ask", - "sort -o *": "ask", - "sort*": "allow", - "stat*": "allow", - "tail*": "allow", - "tree -o *": "ask", - "tree*": "allow", - "uniq*": "allow", - "wc*": "allow", - "whereis*": "allow", - "which*": "allow", - "*": "ask", + "*": "allow", }, webfetch: "allow", - }, - cfg.permission ?? {}, - ) + doom_loop: "ask", + external_directory: "ask", + } + const agentPermission = mergeAgentPermissions(defaultPermission, cfg.permission ?? {}) - const result: Record = { - general: { - name: "general", - description: - "General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.", - tools: { - todoread: false, - todowrite: false, - ...defaultTools, + const planPermission = mergeAgentPermissions( + { + edit: "deny", + bash: { + "cut*": "allow", + "diff*": "allow", + "du*": "allow", + "file *": "allow", + "find * -delete*": "ask", + "find * -exec*": "ask", + "find * -fprint*": "ask", + "find * -fls*": "ask", + "find * -fprintf*": "ask", + "find * -ok*": "ask", + "find *": "allow", + "git diff*": "allow", + "git log*": "allow", + "git show*": "allow", + "git status*": "allow", + "git branch": "allow", + "git branch -v": "allow", + "grep*": "allow", + "head*": "allow", + "less*": "allow", + "ls*": "allow", + "more*": "allow", + "pwd*": "allow", + "rg*": "allow", + "sort --output=*": "ask", + "sort -o *": "ask", + "sort*": "allow", + "stat*": "allow", + "tail*": "allow", + "tree -o *": "ask", + "tree*": "allow", + "uniq*": "allow", + "wc*": "allow", + "whereis*": "allow", + "which*": "allow", + "*": "ask", + }, + webfetch: "allow", }, - options: {}, - permission: agentPermission, - mode: "subagent", - builtIn: true, - }, - build: { - name: "build", - tools: { ...defaultTools }, - options: {}, - permission: agentPermission, - mode: "primary", - builtIn: true, - }, - plan: { - name: "plan", - options: {}, - permission: planPermission, - tools: { - ...defaultTools, + cfg.permission ?? {}, + ) + + const result: Record = { + general: { + name: "general", + description: + "General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.", + tools: { + todoread: false, + todowrite: false, + ...defaultTools, + }, + options: {}, + permission: agentPermission, + mode: "subagent", + builtIn: true, }, - mode: "primary", - builtIn: true, - }, - } - for (const [key, value] of Object.entries(cfg.agent ?? {})) { - if (value.disable) { - delete result[key] - continue - } - let item = result[key] - if (!item) - item = result[key] = { - name: key, - mode: "all", + build: { + name: "build", + tools: { ...defaultTools }, + options: {}, permission: agentPermission, + mode: "primary", + builtIn: true, + }, + plan: { + name: "plan", options: {}, - tools: {}, - builtIn: false, - } - const { name, model, prompt, tools, description, temperature, top_p, mode, permission, color, ...extra } = value - item.options = { - ...item.options, - ...extra, + permission: planPermission, + tools: { + ...defaultTools, + }, + mode: "primary", + builtIn: true, + }, } - if (model) item.model = Provider.parseModel(model) - if (prompt) item.prompt = prompt - if (tools) + for (const [key, value] of Object.entries(cfg.agent ?? {})) { + if (value.disable) { + delete result[key] + continue + } + let item = result[key] + if (!item) + item = result[key] = { + name: key, + mode: "all", + permission: agentPermission, + options: {}, + tools: {}, + builtIn: false, + } + const { name, model, prompt, tools, description, temperature, top_p, mode, color, permission, ...extra } = value + item.options = { + ...item.options, + ...extra, + } + if (model) item.model = Provider.parseModel(model) + if (prompt) item.prompt = prompt + if (tools) + item.tools = { + ...item.tools, + ...tools, + } item.tools = { + ...defaultTools, ...item.tools, - ...tools, } - item.tools = { - ...defaultTools, - ...item.tools, - } - if (description) item.description = description - if (temperature != undefined) item.temperature = temperature - if (top_p != undefined) item.topP = top_p - if (mode) item.mode = mode - if (color) item.color = color - // just here for consistency & to prevent it from being added as an option - if (name) item.name = name + if (description) item.description = description + if (temperature != undefined) item.temperature = temperature + if (top_p != undefined) item.topP = top_p + if (mode) item.mode = mode + if (color) item.color = color + // just here for consistency & to prevent it from being added as an option + if (name) item.name = name - if (permission ?? cfg.permission) { - item.permission = mergeAgentPermissions(cfg.permission ?? {}, permission ?? {}) + if (permission ?? cfg.permission) { + item.permission = mergeAgentPermissions(cfg.permission ?? {}, permission ?? {}) + } } - } - return result - }) + return result + }, + ) export async function get(agent: string) { return state().then((x) => x[agent]) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 33be73ca2d1..c11acd9120f 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -248,6 +248,24 @@ function App() { local.model.cycle(-1) }, }, + { + title: "Favorite cycle", + value: "model.cycle_favorite", + keybind: "model_cycle_favorite", + category: "Agent", + onSelect: () => { + local.model.cycleFavorite(1) + }, + }, + { + title: "Favorite cycle reverse", + value: "model.cycle_favorite_reverse", + keybind: "model_cycle_favorite_reverse", + category: "Agent", + onSelect: () => { + local.model.cycleFavorite(-1) + }, + }, { title: "Switch agent", value: "agent.list", @@ -478,7 +496,7 @@ function App() { code{" "} - v{Installation.VERSION} + v{Installation.displayVersion()} {process.cwd().replace(Global.Path.home, "~")} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index c25e7e370ab..9cd5d8e1d81 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -1,10 +1,11 @@ import { createMemo, createSignal } from "solid-js" import { useLocal } from "@tui/context/local" import { useSync } from "@tui/context/sync" -import { map, pipe, flatMap, entries, filter, isDeepEqual, sortBy, take } from "remeda" +import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda" import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select" import { useDialog } from "@tui/ui/dialog" import { createDialogProviderOptions, DialogProvider } from "./dialog-provider" +import { Keybind } from "@/util/keybind" export function DialogModel() { const local = useLocal() @@ -16,17 +17,49 @@ export function DialogModel() { sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)), ) - const showRecent = createMemo(() => !ref()?.filter && local.model.recent().length > 0 && connected()) const providers = createDialogProviderOptions() const options = createMemo(() => { - return [ - ...(showRecent() - ? local.model.recent().flatMap((item) => { - const provider = sync.data.provider.find((x) => x.id === item.providerID)! + const query = ref()?.filter + const favorites = local.model.favorite() + const recents = local.model.recent() + const currentModel = local.model.current() + + const orderedRecents = currentModel + ? [ + currentModel, + ...recents.filter( + (item) => item.providerID !== currentModel.providerID || item.modelID !== currentModel.modelID, + ), + ] + : recents + + const isCurrent = (item: { providerID: string; modelID: string }) => + currentModel && item.providerID === currentModel.providerID && item.modelID === currentModel.modelID + + const currentIsFavorite = currentModel && favorites.some((fav) => isCurrent(fav)) + + const recentList = orderedRecents + .filter((item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID)) + .slice(0, 5) + + const orderedFavorites = currentModel + ? [...favorites.filter((item) => isCurrent(item)), ...favorites.filter((item) => !isCurrent(item))] + : favorites + + const orderedRecentList = + currentModel && !currentIsFavorite + ? [...recentList.filter((item) => isCurrent(item)), ...recentList.filter((item) => !isCurrent(item))] + : recentList + + const favoriteOptions = + !query && favorites.length > 0 + ? orderedFavorites.flatMap((item) => { + const provider = sync.data.provider.find((x) => x.id === item.providerID) if (!provider) return [] const model = provider.models[item.modelID] if (!model) return [] + const favorite = favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID) return [ { key: item, @@ -35,8 +68,9 @@ export function DialogModel() { modelID: model.id, }, title: model.name ?? item.modelID, - description: provider.name, - category: "Recent", + description: `${provider.name} ★`, + category: "Favorites", + disabled: provider.id === "opencode" && model.id.includes("-nano"), footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, onSelect: () => { dialog.clear() @@ -51,7 +85,44 @@ export function DialogModel() { }, ] }) - : []), + : [] + + const recentOptions = !query + ? orderedRecentList.flatMap((item) => { + const provider = sync.data.provider.find((x) => x.id === item.providerID) + if (!provider) return [] + const model = provider.models[item.modelID] + if (!model) return [] + return [ + { + key: item, + value: { + providerID: provider.id, + modelID: model.id, + }, + title: model.name ?? item.modelID, + description: provider.name, + category: "Recent", + disabled: provider.id === "opencode" && model.id.includes("-nano"), + footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, + onSelect: () => { + dialog.clear() + local.model.set( + { + providerID: provider.id, + modelID: model.id, + }, + { recent: true }, + ) + }, + }, + ] + }) + : [] + + return [ + ...favoriteOptions, + ...recentOptions, ...pipe( sync.data.provider, sortBy( @@ -62,28 +133,46 @@ export function DialogModel() { pipe( provider.models, entries(), - map(([model, info]) => ({ - value: { + map(([model, info]) => { + const value = { providerID: provider.id, modelID: model, - }, - title: info.name ?? model, - description: connected() ? provider.name : undefined, - category: connected() ? provider.name : undefined, - disabled: provider.id === "opencode" && model.includes("-nano"), - footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, - onSelect() { - dialog.clear() - local.model.set( - { - providerID: provider.id, - modelID: model, - }, - { recent: true }, - ) - }, - })), - filter((x) => !showRecent() || !local.model.recent().find((y) => isDeepEqual(y, x.value))), + } + const favorite = favorites.some( + (item) => item.providerID === value.providerID && item.modelID === value.modelID, + ) + return { + value, + title: info.name ?? model, + description: connected() ? `${provider.name}${favorite ? " ★" : ""}` : undefined, + category: connected() ? provider.name : undefined, + disabled: provider.id === "opencode" && model.includes("-nano"), + footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, + onSelect() { + dialog.clear() + local.model.set( + { + providerID: provider.id, + modelID: model, + }, + { recent: true }, + ) + }, + } + }), + filter((x) => { + if (query) return true + const value = x.value + const inFavorites = favorites.some( + (item) => item.providerID === value.providerID && item.modelID === value.modelID, + ) + const inRecents = orderedRecents.some( + (item) => item.providerID === value.providerID && item.modelID === value.modelID, + ) + if (inFavorites) return false + if (inRecents) return false + return true + }), sortBy((x) => x.title), ), ), @@ -113,6 +202,13 @@ export function DialogModel() { dialog.replace(() => ) }, }, + { + keybind: Keybind.parse("ctrl+f")[0], + title: "Favorite", + onTrigger: (option) => { + local.model.toggleFavorite(option.value as { providerID: string; modelID: string }) + }, + }, ]} ref={setRef} title="Select model" diff --git a/packages/opencode/src/cli/cmd/tui/component/logo.tsx b/packages/opencode/src/cli/cmd/tui/component/logo.tsx index 59db5fe7d13..5b3cb37d7a9 100644 --- a/packages/opencode/src/cli/cmd/tui/component/logo.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/logo.tsx @@ -3,7 +3,12 @@ import { TextAttributes } from "@opentui/core" import { For } from "solid-js" import { useTheme } from "@tui/context/theme" -const LOGO_LEFT = [` `, `█▀▀█ █▀▀█ █▀▀█ █▀▀▄`, `█░░█ █░░█ █▀▀▀ █░░█`, `▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀`] +const LOGO_LEFT = [ + ` `, + `▉▀▀▀ █▉ █ ▉▉ ▉▚▚ ▞▞`, + `▀▀▀▉ ██▀▀█ ▉▉ ▉ ▚▚ ▞▞ `, + `▀▀▀▀ ▀▀ ▀ ▀▀▀▀▀ ▝▀▀▘ `, +] const LOGO_RIGHT = [` ▄ `, `█▀▀▀ █▀▀█ █▀▀█ █▀▀█`, `█░░░ █░░█ █░░█ █▀▀▀`, `▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`] @@ -22,7 +27,7 @@ export function Logo() { )} - {Installation.VERSION} + {Installation.displayVersion()} ) diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index c3b38aab2a2..1703b365d24 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -114,18 +114,34 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ providerID: string modelID: string }[] + favorite: { + providerID: string + modelID: string + }[] }>({ ready: false, model: {}, recent: [], + favorite: [], }) const file = Bun.file(path.join(Global.Path.state, "model.json")) + function save() { + Bun.write( + file, + JSON.stringify({ + recent: modelStore.recent, + favorite: modelStore.favorite, + }), + ) + } + file .json() .then((x) => { - setModelStore("recent", x.recent) + if (Array.isArray(x.recent)) setModelStore("recent", x.recent) + if (Array.isArray(x.favorite)) setModelStore("favorite", x.favorite) }) .catch(() => {}) .finally(() => { @@ -184,6 +200,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ recent() { return modelStore.recent }, + favorite() { + return modelStore.favorite + }, parsed: createMemo(() => { const value = currentModel() const provider = sync.data.provider.find((x) => x.id === value.providerID)! @@ -206,6 +225,33 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (!val) return setModelStore("model", agent.current().name, { ...val }) }, + cycleFavorite(direction: 1 | -1) { + const favorites = modelStore.favorite.filter((item) => isModelValid(item)) + if (!favorites.length) { + toast.show({ + variant: "info", + message: "Add a favorite model to use this shortcut", + duration: 3000, + }) + return + } + const current = currentModel() + let index = favorites.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID) + if (index === -1) { + index = direction === 1 ? 0 : favorites.length - 1 + } else { + index += direction + if (index < 0) index = favorites.length - 1 + if (index >= favorites.length) index = 0 + } + const next = favorites[index] + if (!next) return + setModelStore("model", agent.current().name, { ...next }) + const uniq = uniqueBy([next, ...modelStore.recent], (x) => x.providerID + x.modelID) + if (uniq.length > 10) uniq.pop() + setModelStore("recent", uniq) + save() + }, set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) { batch(() => { if (!isModelValid(model)) { @@ -219,15 +265,30 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ setModelStore("model", agent.current().name, model) if (options?.recent) { const uniq = uniqueBy([model, ...modelStore.recent], (x) => x.providerID + x.modelID) - if (uniq.length > 5) uniq.pop() + if (uniq.length > 10) uniq.pop() setModelStore("recent", uniq) - Bun.write( - file, - JSON.stringify({ - recent: modelStore.recent, - }), - ) + save() + } + }) + }, + toggleFavorite(model: { providerID: string; modelID: string }) { + batch(() => { + if (!isModelValid(model)) { + toast.show({ + message: `Model ${model.providerID}/${model.modelID} is not valid`, + variant: "warning", + duration: 3000, + }) + return } + const exists = modelStore.favorite.some( + (x) => x.providerID === model.providerID && x.modelID === model.modelID, + ) + const next = exists + ? modelStore.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID) + : [model, ...modelStore.favorite] + setModelStore("favorite", next) + save() }) }, } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index f8526e72be8..df69bc905bc 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -28,6 +28,7 @@ import { Prompt, type PromptRef } from "@tui/component/prompt" import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk" import { useLocal } from "@tui/context/local" import { Locale } from "@/util/locale" +import { Token } from "@/util/token" import type { Tool } from "@/tool/tool" import type { ReadTool } from "@/tool/read" import type { WriteTool } from "@/tool/write" @@ -80,6 +81,7 @@ const context = createContext<{ conceal: () => boolean showThinking: () => boolean showTimestamps: () => boolean + showTokens: () => boolean }>() function use() { @@ -106,11 +108,20 @@ export function Session() { return messages().findLast((x) => x.role === "assistant") }) + const local = useLocal() + + const contextLimit = createMemo(() => { + const c = local.model.current() + const provider = sync.data.provider.find((p) => p.id === c.providerID) + return provider?.models[c.modelID]?.limit.context ?? 200000 + }) + const dimensions = useTerminalDimensions() const [sidebar, setSidebar] = createSignal<"show" | "hide" | "auto">(kv.get("sidebar", "auto")) const [conceal, setConceal] = createSignal(true) const [showThinking, setShowThinking] = createSignal(true) const [showTimestamps, setShowTimestamps] = createSignal(kv.get("timestamps", "hide") === "show") + const [showTokens, setShowTokens] = createSignal(kv.get("tokens", "hide") === "show") const wide = createMemo(() => dimensions().width > 120) const sidebarVisible = createMemo(() => sidebar() === "show" || (sidebar() === "auto" && wide())) @@ -205,8 +216,6 @@ export function Session() { }, 50) } - const local = useLocal() - function moveChild(direction: number) { const parentID = session()?.parentID ?? session()?.id let children = sync.data.session @@ -429,6 +438,19 @@ export function Session() { dialog.clear() }, }, + { + title: "Toggle tokens", + value: "session.toggle.tokens", + category: "Session", + onSelect: (dialog) => { + setShowTokens((prev) => { + const next = !prev + kv.set("tokens", next ? "show" : "hide") + return next + }) + dialog.clear() + }, + }, { title: "Page up", value: "session.page.up", @@ -730,6 +752,7 @@ export function Session() { conceal, showThinking, showTimestamps, + showTokens, }} > @@ -865,6 +888,7 @@ export function Session() { last={lastAssistant()?.id === message.id} message={message as AssistantMessage} parts={sync.data.part[message.id] ?? []} + contextLimit={contextLimit()} /> @@ -918,6 +942,13 @@ function UserMessage(props: { const queued = createMemo(() => props.pending && props.message.id > props.pending) const color = createMemo(() => (queued() ? theme.accent : theme.secondary)) + const individualTokens = createMemo(() => { + return props.parts.reduce((sum, part) => { + if (part.type === "text") return sum + Token.estimate(part.text) + return sum + }, 0) + }) + const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction")) return ( @@ -978,6 +1009,9 @@ function UserMessage(props: { > QUEUED + 0}> + ⬝~{individualTokens().toLocaleString()} tok + @@ -995,7 +1029,8 @@ function UserMessage(props: { ) } -function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) { +function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean; contextLimit: number }) { + const ctx = use() const local = useLocal() const { theme } = useTheme() const sync = useSync() @@ -1005,12 +1040,71 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish) }) + // Find the parent user message (reused by duration and token calculations) + const user = createMemo(() => messages().find((x) => x.role === "user" && x.id === props.message.parentID)) + const duration = createMemo(() => { if (!final()) return 0 if (!props.message.time.completed) return 0 - const user = messages().find((x) => x.role === "user" && x.id === props.message.parentID) - if (!user || !user.time) return 0 - return props.message.time.completed - user.time.created + const u = user() + if (!u || !u.time) return 0 + return props.message.time.completed - u.time.created + }) + + // OUT tokens (sent TO API) - includes user text + tool results from previous assistant + const outEstimate = createMemo(() => props.message.sentEstimate) + + // IN tokens (from API TO computer) + const inTokens = createMemo(() => props.message.tokens.output) + const inEstimate = createMemo(() => props.message.outputEstimate) + + // Reasoning tokens (must be defined BEFORE inDisplay) + const reasoningTokens = createMemo(() => props.message.tokens.reasoning) + const reasoningEstimate = createMemo(() => props.message.reasoningEstimate) + + const outDisplay = createMemo(() => { + const estimate = outEstimate() + if (estimate !== undefined) return "~" + estimate.toLocaleString() + const tokens = props.message.tokens.input + if (tokens > 0) return tokens.toLocaleString() + return "0" + }) + + const inDisplay = createMemo(() => { + const estimate = inEstimate() + if (estimate !== undefined) return "~" + estimate.toLocaleString() + const tokens = inTokens() + if (tokens > 0) return tokens.toLocaleString() + // Show ~0 during streaming when we have reasoning but no output yet + if (reasoningEstimate() !== undefined || reasoningTokens() > 0) return "~0" + return undefined + }) + + const tokensDisplay = createMemo(() => { + const inVal = inDisplay() + if (!inVal) return undefined + return `${inVal}↓/${outDisplay()}↑` + }) + + const reasoningDisplay = createMemo(() => { + const estimate = reasoningEstimate() + if (estimate !== undefined) return "~" + estimate.toLocaleString() + const tokens = reasoningTokens() + if (tokens > 0) return tokens.toLocaleString() + return undefined + }) + + const contextEstimate = createMemo(() => props.message.contextEstimate) + + const cumulativeTokens = createMemo(() => { + const estimate = contextEstimate() + if (estimate !== undefined) return estimate + return props.message.tokens.input + props.message.tokens.cache.read + props.message.tokens.cache.write + }) + + const percentage = createMemo(() => { + if (!props.contextLimit) return 0 + return Math.round((cumulativeTokens() / props.contextLimit) * 100) }) return ( @@ -1054,6 +1148,22 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las · {Locale.duration(duration())} + + + {" "} + ⬝ {tokensDisplay()} tok + + {" · "} + {reasoningDisplay()} think + + 0 || inEstimate() !== undefined || reasoningEstimate() !== undefined} + > + {" · "} + {cumulativeTokens().toLocaleString()} context ({percentage()}%) + + + diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index ca4ec322eaf..b8d2a5b144e 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -253,7 +253,7 @@ export function DialogSelect(props: DialogSelectProps) { )} - + {(item) => ( diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 5e1ad9dc405..7be404d955c 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -1,6 +1,7 @@ import z from "zod" import { Config } from "../config/config" import { Instance } from "../project/instance" +import { State } from "../project/state" import PROMPT_INITIALIZE from "./template/initialize.txt" import { Bus } from "../bus" import { Identifier } from "../id/id" @@ -36,32 +37,36 @@ export namespace Command { }) export type Info = z.infer - const state = Instance.state(async () => { - const cfg = await Config.get() + const state = State.register( + "command", + () => Instance.directory, + async () => { + const cfg = await Config.get() - const result: Record = {} + const result: Record = {} - for (const [name, command] of Object.entries(cfg.command ?? {})) { - result[name] = { - name, - agent: command.agent, - model: command.model, - description: command.description, - template: command.template, - subtask: command.subtask, + for (const [name, command] of Object.entries(cfg.command ?? {})) { + result[name] = { + name, + agent: command.agent, + model: command.model, + description: command.description, + template: command.template, + subtask: command.subtask, + } } - } - if (result[Default.INIT] === undefined) { - result[Default.INIT] = { - name: Default.INIT, - description: "create/update AGENTS.md", - template: PROMPT_INITIALIZE.replace("${path}", Instance.worktree), + if (result[Default.INIT] === undefined) { + result[Default.INIT] = { + name: Default.INIT, + description: "create/update AGENTS.md", + template: PROMPT_INITIALIZE.replace("${path}", Instance.worktree), + } } - } - return result - }) + return result + }, + ) export async function get(name: string) { return state().then((x) => x[name]) diff --git a/packages/opencode/src/config/backup.ts b/packages/opencode/src/config/backup.ts new file mode 100644 index 00000000000..9df6ba2c7dc --- /dev/null +++ b/packages/opencode/src/config/backup.ts @@ -0,0 +1,17 @@ +import fs from "fs/promises" + +export async function createBackup(filepath: string): Promise { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-") + const backupPath = `${filepath}.bak-${timestamp}` + + if (await Bun.file(filepath).exists()) { + await fs.copyFile(filepath, backupPath) + } + + return backupPath +} + +export async function restoreBackup(backupPath: string, targetPath: string): Promise { + await fs.copyFile(backupPath, targetPath) + await fs.unlink(backupPath) +} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 28b8ca3b2ae..f400e4abb70 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -4,51 +4,113 @@ import os from "os" import z from "zod" import { Filesystem } from "../util/filesystem" import { ModelsDev } from "../provider/models" -import { mergeDeep, pipe } from "remeda" +import { mergeDeep } from "remeda" import { Global } from "../global" import fs from "fs/promises" -import { lazy } from "../util/lazy" +import { resolveGlobalFile } from "./global-file" import { NamedError } from "@opencode-ai/util/error" import { Flag } from "../flag/flag" import { Auth } from "../auth" import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser" import { Instance } from "../project/instance" +import { State } from "../project/state" import { LSPServer } from "../lsp/server" import { BunProc } from "@/bun" import { Installation } from "@/installation" import { ConfigMarkdown } from "./markdown" +import { Bus } from "../bus" +import type { ConfigDiff } from "./diff" +import { pathToFileURL } from "url" export namespace Config { const log = Log.create({ service: "config" }) + const WINDOWS_RELATIVE_PREFIXES = [".\\", "..\\", "~\\"] - // Custom merge function that concatenates plugin arrays instead of replacing them - function mergeConfigWithPlugins(target: Info, source: Info): Info { + const mergeConfigWithPlugins = (target: Info, source: Info): Info => { const merged = mergeDeep(target, source) - // If both configs have plugin arrays, concatenate them instead of replacing if (target.plugin && source.plugin) { - const pluginSet = new Set([...target.plugin, ...source.plugin]) - merged.plugin = Array.from(pluginSet) + merged.plugin = Array.from(new Set([...target.plugin, ...source.plugin])) } return merged } - export const state = Instance.state(async () => { - const auth = await Auth.all() - let result = await global() + const isPathLikePluginSpecifier = (value: unknown): value is string => { + if (typeof value !== "string") return false + if (value.startsWith("file://")) return true + if (value.startsWith("./") || value.startsWith("../")) return true + if (value.startsWith("~/")) return true + if (WINDOWS_RELATIVE_PREFIXES.some((prefix) => value.startsWith(prefix))) { + return true + } + if (value.startsWith("/") || path.isAbsolute(value)) { + return true + } + return false + } - // Override with custom config if provided - if (Flag.OPENCODE_CONFIG) { - result = mergeConfigWithPlugins(result, await loadFile(Flag.OPENCODE_CONFIG)) - log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG }) + const resolvePluginFileReference = (plugin: string, configFilepath: string): string => { + if (plugin.startsWith("file://")) { + return plugin + } + + const normalizeWindowsPath = (input: string) => input.replace(/\\/g, "/") + + if (plugin.startsWith("~/")) { + const homePath = path.join(os.homedir(), plugin.slice(2)) + return pathToFileURL(homePath).href } + if (WINDOWS_RELATIVE_PREFIXES.some((prefix) => plugin.startsWith(prefix))) { + const withoutPrefix = plugin.startsWith("~\\") + ? path.join(os.homedir(), plugin.slice(2)) + : path.resolve(path.dirname(configFilepath), plugin) + return pathToFileURL(withoutPrefix).href + } + + if (path.isAbsolute(plugin)) { + return pathToFileURL(plugin).href + } + + try { + const base = pathToFileURL(configFilepath).href + const resolved = new URL(plugin, base).href + return normalizeWindowsPath(resolved) + } catch { + return plugin + } + } + + export const Event = { + Updated: Bus.event( + "config.updated", + z.object({ + scope: z.enum(["project", "global"]), + directory: z.string().optional(), + refreshed: z.boolean().optional(), + before: z.any(), + after: z.any(), + diff: z.any(), + }), + ), + } + + async function loadStateFromDisk() { + const directory = Instance.directory + const worktree = Instance.worktree + const auth = await Auth.all() + let result = await global() for (const file of ["opencode.jsonc", "opencode.json"]) { - const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree) + const found = await Filesystem.findUp(file, directory, worktree) for (const resolved of found.toReversed()) { result = mergeConfigWithPlugins(result, await loadFile(resolved)) } } + if (Flag.OPENCODE_CONFIG) { + result = mergeConfigWithPlugins(result, await loadFile(Flag.OPENCODE_CONFIG)) + log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG }) + } + if (Flag.OPENCODE_CONFIG_CONTENT) { result = mergeConfigWithPlugins(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT)) log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") @@ -71,8 +133,8 @@ export namespace Config { ...(await Array.fromAsync( Filesystem.up({ targets: [".opencode"], - start: Instance.directory, - stop: Instance.worktree, + start: directory, + stop: worktree, }), )), ] @@ -83,6 +145,7 @@ export namespace Config { } const promises: Promise[] = [] + const pluginFiles: string[] = [] for (const dir of directories) { await assertValid(dir) @@ -90,7 +153,6 @@ export namespace Config { for (const file of ["opencode.jsonc", "opencode.json"]) { log.debug(`loading config from ${path.join(dir, file)}`) result = mergeConfigWithPlugins(result, await loadFile(path.join(dir, file))) - // to satisy the type checker result.agent ??= {} result.mode ??= {} result.plugin ??= [] @@ -101,11 +163,16 @@ export namespace Config { result.command = mergeDeep(result.command ?? {}, await loadCommand(dir)) result.agent = mergeDeep(result.agent, await loadAgent(dir)) result.agent = mergeDeep(result.agent, await loadMode(dir)) - result.plugin.push(...(await loadPlugin(dir))) + pluginFiles.push(...(await loadPlugin(dir))) } await Promise.allSettled(promises) - // Migrate deprecated mode field to agent field + if (!result.plugin) { + result.plugin = [] + } + result.plugin.push(...pluginFiles) + result.plugin = Array.from(new Set(result.plugin)) + for (const [name, mode] of Object.entries(result.mode)) { result.agent = mergeDeep(result.agent ?? {}, { [name]: { @@ -121,12 +188,6 @@ export namespace Config { if (!result.username) result.username = os.userInfo().username - // Handle migration from autoshare to share field - if (result.autoshare === true && !result.share) { - result.share = "auto" - } - - // Handle migration from autoshare to share field if (result.autoshare === true && !result.share) { result.share = "auto" } @@ -137,7 +198,14 @@ export namespace Config { config: result, directories, } - }) + } + + export const state = State.register("config", () => Instance.directory, loadStateFromDisk) + + export async function readFreshConfig() { + const state = await loadStateFromDisk() + return state.config + } const INVALID_DIRS = new Bun.Glob(`{${["agents", "commands", "plugins", "tools"].join(",")}}/`) async function assertValid(dir: string) { @@ -428,6 +496,8 @@ export namespace Config { model_list: z.string().optional().default("m").describe("List available models"), model_cycle_recent: z.string().optional().default("f2").describe("Next recently used model"), model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recently used model"), + model_cycle_favorite: z.string().optional().default("none").describe("Next favorite model"), + model_cycle_favorite_reverse: z.string().optional().default("none").describe("Previous favorite model"), command_list: z.string().optional().default("ctrl+p").describe("List available commands"), agent_list: z.string().optional().default("a").describe("List agents"), agent_cycle: z.string().optional().default("tab").describe("Next agent"), @@ -668,13 +738,13 @@ export namespace Config { export type Info = z.output - export const global = lazy(async () => { - let result: Info = pipe( - {}, - mergeDeep(await loadFile(path.join(Global.Path.config, "config.json"))), - mergeDeep(await loadFile(path.join(Global.Path.config, "opencode.json"))), - mergeDeep(await loadFile(path.join(Global.Path.config, "opencode.jsonc"))), - ) + async function loadGlobalConfig(): Promise { + const globalFile = await resolveGlobalFile() + + let result: Info = {} + result = mergeConfigWithPlugins(result, await loadFile(path.join(Global.Path.config, "config.json"))) + result = mergeConfigWithPlugins(result, await loadFile(path.join(Global.Path.config, "opencode.json"))) + result = mergeConfigWithPlugins(result, await loadFile(globalFile)) await import(path.join(Global.Path.config, "config"), { with: { @@ -685,14 +755,18 @@ export namespace Config { const { provider, model, ...rest } = mod.default if (provider && model) result.model = `${provider}/${model}` result["$schema"] = "https://opencode.ai/config.json" - result = mergeDeep(result, rest) + result = mergeConfigWithPlugins(result, rest) await Bun.write(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2)) await fs.unlink(path.join(Global.Path.config, "config")) }) .catch(() => {}) return result - }) + } + + export async function global() { + return loadGlobalConfig() + } async function loadFile(filepath: string): Promise { log.info("loading", { path: filepath }) @@ -779,12 +853,12 @@ export namespace Config { await Bun.write(configFilepath, JSON.stringify(parsed.data, null, 2)) } const data = parsed.data - if (data.plugin) { + if (data.plugin?.length) { for (let i = 0; i < data.plugin.length; i++) { const plugin = data.plugin[i] - try { - data.plugin[i] = import.meta.resolve!(plugin, configFilepath) - } catch (err) {} + if (isPathLikePluginSpecifier(plugin)) { + data.plugin[i] = resolvePluginFileReference(plugin, configFilepath) + } } } return data @@ -825,11 +899,22 @@ export namespace Config { return state().then((x) => x.config) } - export async function update(config: Info) { - const filepath = path.join(Instance.directory, "config.json") - const existing = await loadFile(filepath) - await Bun.write(filepath, JSON.stringify(mergeDeep(existing, config), null, 2)) - await Instance.dispose() + export async function update(input: { scope?: "project" | "global"; update: Info; directory?: string }): Promise<{ + before: Info + after: Info + diff: ConfigDiff + diffForPublish: ConfigDiff + filepath: string + }> { + const scope = input.scope ?? "project" + const directory = input.directory ?? Instance.directory + + const { update: persistUpdate } = await import("./persist") + return persistUpdate({ + scope, + update: input.update, + directory, + }) } export async function directories() { diff --git a/packages/opencode/src/config/diff.ts b/packages/opencode/src/config/diff.ts new file mode 100644 index 00000000000..9b2edb714f1 --- /dev/null +++ b/packages/opencode/src/config/diff.ts @@ -0,0 +1,123 @@ +import { isDeepEqual } from "remeda" +import type { Config } from "./config" + +export interface ConfigDiff { + provider?: boolean + providerKeys?: { added: string[]; removed: string[]; modified: string[] } + mcp?: boolean + mcpKeys?: { added: string[]; removed: string[]; modified: string[] } + lsp?: boolean + formatter?: boolean + watcher?: boolean + plugin?: boolean + pluginAdded?: string[] + pluginRemoved?: string[] + agent?: boolean + command?: boolean + permission?: boolean + tools?: boolean + instructions?: boolean + share?: boolean + autoshare?: boolean + theme?: boolean + model?: boolean + small_model?: boolean + disabled_providers?: boolean +} + +function computeKeysChanged( + before: Record | undefined, + after: Record | undefined, +): { added: string[]; removed: string[]; modified: string[] } { + const beforeKeys = Object.keys(before ?? {}) + const afterKeys = Object.keys(after ?? {}) + + const added = afterKeys.filter((k) => !beforeKeys.includes(k)) + const removed = beforeKeys.filter((k) => !afterKeys.includes(k)) + const modified = afterKeys.filter((k) => { + if (!beforeKeys.includes(k)) return false + return !isDeepEqual(before?.[k], after?.[k]) + }) + + return { added, removed, modified } +} + +export function computeDiff(before: Config.Info, after: Config.Info): ConfigDiff { + const diff: ConfigDiff = {} + + if (!isDeepEqual(before.provider, after.provider)) { + diff.provider = true + diff.providerKeys = computeKeysChanged(before.provider, after.provider) + } + + if (!isDeepEqual(before.mcp, after.mcp)) { + diff.mcp = true + diff.mcpKeys = computeKeysChanged(before.mcp, after.mcp) + } + + if (!isDeepEqual(before.lsp, after.lsp)) { + diff.lsp = true + } + + if (!isDeepEqual(before.formatter, after.formatter)) { + diff.formatter = true + } + + if (!isDeepEqual(before.watcher, after.watcher)) { + diff.watcher = true + } + + if (!isDeepEqual(before.plugin, after.plugin)) { + diff.plugin = true + const beforePlugins = before.plugin ?? [] + const afterPlugins = after.plugin ?? [] + diff.pluginAdded = afterPlugins.filter((p) => !beforePlugins.includes(p)) + diff.pluginRemoved = beforePlugins.filter((p) => !afterPlugins.includes(p)) + } + + if (!isDeepEqual(before.agent, after.agent)) { + diff.agent = true + } + + if (!isDeepEqual(before.command, after.command)) { + diff.command = true + } + + if (!isDeepEqual(before.permission, after.permission)) { + diff.permission = true + } + + if (!isDeepEqual(before.tools, after.tools)) { + diff.tools = true + } + + if (!isDeepEqual(before.instructions, after.instructions)) { + diff.instructions = true + } + + if (before.share !== after.share) { + diff.share = true + } + + if (before.autoshare !== after.autoshare) { + diff.autoshare = true + } + + if (before.theme !== after.theme) { + diff.theme = true + } + + if (before.model !== after.model) { + diff.model = true + } + + if (before.small_model !== after.small_model) { + diff.small_model = true + } + + if (!isDeepEqual(before.disabled_providers, after.disabled_providers)) { + diff.disabled_providers = true + } + + return diff +} diff --git a/packages/opencode/src/config/error.ts b/packages/opencode/src/config/error.ts new file mode 100644 index 00000000000..37ec31f5123 --- /dev/null +++ b/packages/opencode/src/config/error.ts @@ -0,0 +1,45 @@ +import z from "zod" +import { NamedError } from "@opencode-ai/util/error" + +export const ConfigUpdateError = NamedError.create( + "ConfigUpdateError", + z.object({ + filepath: z.string(), + scope: z.enum(["project", "global"]), + directory: z.string(), + cause: z.any().optional(), + }), +) + +export const ConfigValidationError = NamedError.create( + "ConfigValidationError", + z.object({ + filepath: z.string(), + errors: z.array( + z.object({ + field: z.string(), + message: z.string(), + expected: z.string().optional(), + received: z.string().optional(), + }), + ), + }), +) + +export const ConfigWriteConflictError = NamedError.create( + "ConfigWriteConflictError", + z.object({ + filepath: z.string(), + timeout: z.number(), + waitedMs: z.number(), + }), +) + +export const ConfigWriteError = NamedError.create( + "ConfigWriteError", + z.object({ + filepath: z.string(), + operation: z.enum(["create", "write", "backup", "restore"]), + cause: z.any(), + }), +) diff --git a/packages/opencode/src/config/global-file.ts b/packages/opencode/src/config/global-file.ts new file mode 100644 index 00000000000..40d722f81d4 --- /dev/null +++ b/packages/opencode/src/config/global-file.ts @@ -0,0 +1,8 @@ +import fs from "fs/promises" +import path from "path" +import { Global } from "../global" + +export async function resolveGlobalFile(): Promise { + await fs.mkdir(Global.Path.config, { recursive: true }) + return path.join(Global.Path.config, "opencode.jsonc") +} diff --git a/packages/opencode/src/config/hot-reload.ts b/packages/opencode/src/config/hot-reload.ts new file mode 100644 index 00000000000..3f769b2441e --- /dev/null +++ b/packages/opencode/src/config/hot-reload.ts @@ -0,0 +1,3 @@ +export function isConfigHotReloadEnabled(): boolean { + return process.env.OPENCODE_CONFIG_HOT_RELOAD === "true" +} diff --git a/packages/opencode/src/config/invalidation.ts b/packages/opencode/src/config/invalidation.ts new file mode 100644 index 00000000000..09c684d0424 --- /dev/null +++ b/packages/opencode/src/config/invalidation.ts @@ -0,0 +1,188 @@ +import { Bus } from "@/bus" +import { Config } from "./config" +import { Instance } from "@/project/instance" +import { Log } from "@/util/log" +import type { ConfigDiff } from "./diff" +import { Context } from "../util/context" +import { isConfigHotReloadEnabled } from "./hot-reload" + +const log = Log.create({ service: "config.invalidation" }) + +type ApplyInput = { + scope: "project" | "global" + directory?: string + diff: ConfigDiff + refreshed?: boolean +} + +let setupPromise: Promise | undefined +async function invalidateProvider(diff: ConfigDiff): Promise { + await Instance.invalidate("provider") +} + +async function invalidateMCP(diff: ConfigDiff): Promise { + await Instance.invalidate("mcp") +} + +async function invalidateLSP(diff: ConfigDiff): Promise { + await Instance.invalidate("lsp") +} + +async function invalidateFileWatcher(): Promise { + await Instance.invalidate("filewatcher") +} + +async function invalidatePlugin(diff: ConfigDiff): Promise { + await Instance.invalidate("plugin") +} + +async function invalidateToolRegistry(): Promise { + await Instance.invalidate("tool-registry") +} + +async function invalidatePermission(): Promise { + await Instance.invalidate("permission") +} + +async function invalidateCommandAgentFormat(diff: ConfigDiff): Promise { + if (diff.command) await Instance.invalidate("command") + if (diff.agent) await Instance.invalidate("agent") + if (diff.formatter) await Instance.invalidate("format") +} + +async function invalidateUIAndPrompts(diff: ConfigDiff): Promise { + if (diff.instructions) await Instance.invalidate("instructions") + if (diff.theme) await Instance.invalidate("theme") +} + +async function applyInternal(input: ApplyInput) { + const { diff, scope } = input + const targetDirectory = input.directory ?? process.cwd() + const directoryForLog = input.directory ?? targetDirectory + const alreadyRefreshed = input.refreshed === true + + await Instance.provide({ + directory: targetDirectory, + fn: async () => { + if (!alreadyRefreshed) { + await Instance.invalidate("config") + } + log.info("config.invalidate.stateRefreshed", { scope, directory: directoryForLog }) + + if (Object.keys(diff).length === 0) { + log.info("config.update.noop", { scope, directory: directoryForLog }) + return + } + + const sections = Object.keys(diff).filter((k) => diff[k as keyof ConfigDiff] === true) + const targets = new Set() + const tasks: Promise[] = [] + const providerChanged = diff.provider || diff.model || diff.small_model || diff.disabled_providers + if (providerChanged) { + targets.add("provider") + tasks.push(invalidateProvider(diff)) + } + + const mcpChanged = diff.mcp + if (mcpChanged) { + targets.add("mcp") + tasks.push(invalidateMCP(diff)) + } + + const lspChanged = diff.lsp || diff.formatter + if (lspChanged) { + targets.add("lsp") + tasks.push(invalidateLSP(diff)) + } + + const watcherChanged = diff.watcher + if (watcherChanged) { + targets.add("filewatcher") + tasks.push(invalidateFileWatcher()) + } + + const pluginChanged = diff.plugin + if (pluginChanged) { + targets.add("plugin") + tasks.push(invalidatePlugin(diff)) + targets.add("tool-registry") + tasks.push(invalidateToolRegistry()) + } + + const permissionChanged = diff.permission + if (permissionChanged) { + targets.add("permission") + tasks.push(invalidatePermission()) + } + + const commandAgentFormatChanged = diff.command || diff.agent || diff.formatter + if (commandAgentFormatChanged) { + if (diff.command) targets.add("command") + if (diff.agent) targets.add("agent") + if (diff.formatter) targets.add("format") + tasks.push(invalidateCommandAgentFormat(diff)) + } + + const shareSettingsChanged = diff.share || diff.autoshare + const uiChanged = diff.theme || diff.instructions || shareSettingsChanged + if (uiChanged) { + if (diff.theme) targets.add("theme") + if (diff.instructions) targets.add("instructions") + if (shareSettingsChanged) targets.add("share-settings") + tasks.push(invalidateUIAndPrompts(diff)) + } + + log.info("config.invalidate.start", { + scope, + directory: directoryForLog, + sections, + targets: Array.from(targets), + }) + + try { + await Promise.all(tasks) + } catch (error) { + log.error("Targeted config invalidation failed", { + error: String(error), + }) + } + + log.info("config.invalidate.complete", { + scope, + directory: directoryForLog, + sections, + targets: Array.from(targets), + }) + }, + }) +} +export namespace ConfigInvalidation { + export async function apply(input: ApplyInput) { + try { + await applyInternal(input) + } catch (error) { + if (error instanceof Context.NotFound) { + log.warn("config.invalidate.missingContext", { error: String(error) }) + return + } + throw error + } + } + + export async function setup() { + if (setupPromise) { + return setupPromise + } + + setupPromise = (async () => { + if (isConfigHotReloadEnabled()) { + Bus.subscribe(Config.Event.Updated, async (event) => { + const { diff, scope, directory, refreshed } = event.properties as any + await apply({ diff, scope, directory, refreshed }) + }) + } + })() + + return setupPromise + } +} diff --git a/packages/opencode/src/config/lock.ts b/packages/opencode/src/config/lock.ts new file mode 100644 index 00000000000..007207d6c4a --- /dev/null +++ b/packages/opencode/src/config/lock.ts @@ -0,0 +1,133 @@ +import path from "path" +import fs from "fs/promises" +import { constants } from "fs" +import { Log } from "@/util/log" + +const log = Log.create({ service: "config.lock" }) +const fileLocks = new Map>() +const LOCKFILE_SUFFIX = ".lock" +const LOCKFILE_STALE_AFTER_MS = 60000 +const LOCKFILE_RETRY_DELAY_MS = 25 + +interface LockOptions { + timeout?: number + staleAfter?: number +} + +function buildLockfilePath(target: string) { + return `${target}${LOCKFILE_SUFFIX}` +} + +async function removeLockfile(lockfile: string): Promise { + await fs.unlink(lockfile).catch((error: NodeJS.ErrnoException) => { + if (error?.code === "ENOENT") return + log.warn("failed to remove lockfile", { filepath: lockfile, error: String(error) }) + }) +} + +async function acquireFilesystemLock(params: { + filepath: string + timeout: number + staleAfter: number + startTime: number +}): Promise<() => Promise> { + const lockfile = buildLockfilePath(params.filepath) + await fs.mkdir(path.dirname(lockfile), { recursive: true }) + let warned = false + + while (true) { + const handle = await fs + .open(lockfile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600) + .catch((error: NodeJS.ErrnoException) => { + if (error?.code === "EEXIST") return null + throw error + }) + + if (handle) { + const payload = JSON.stringify({ + pid: process.pid, + createdAt: new Date().toISOString(), + }) + await handle.write(payload) + await handle.close() + return async () => removeLockfile(lockfile) + } + + const waited = Date.now() - params.startTime + + if (!warned && waited > 5000) { + warned = true + log.warn("waiting for filesystem lock", { + filepath: params.filepath, + waited, + }) + } + + if (waited > params.timeout) { + throw new Error(`Lock timeout: could not acquire filesystem lock for ${params.filepath} after ${waited}ms`) + } + + const stat = await fs.stat(lockfile).catch((error: NodeJS.ErrnoException) => { + if (error?.code === "ENOENT") return + throw error + }) + + if (stat) { + const age = Date.now() - stat.mtimeMs + if (age > params.staleAfter) { + log.warn("removing stale lockfile", { + filepath: params.filepath, + age, + }) + await removeLockfile(lockfile) + } + } + + await Bun.sleep(LOCKFILE_RETRY_DELAY_MS) + } +} + +export async function acquireLock(filepath: string, options?: LockOptions): Promise<() => Promise> { + const normalized = path.normalize(filepath) + const timeout = options?.timeout ?? 30000 + const staleAfter = options?.staleAfter ?? LOCKFILE_STALE_AFTER_MS + const startTime = Date.now() + + while (fileLocks.has(normalized)) { + const waited = Date.now() - startTime + + if (waited > 5000 && waited < 5100) { + log.warn("lock acquisition taking longer than expected", { + filepath: normalized, + waited, + }) + } + + if (waited > timeout) { + throw new Error(`Lock timeout: could not acquire lock for ${normalized} after ${waited}ms`) + } + + await fileLocks.get(normalized) + await Bun.sleep(10) + } + + let releaseFn: () => void + const lockPromise = new Promise((resolve) => { + releaseFn = resolve + }) + + fileLocks.set(normalized, lockPromise) + + const releaseFilesystem = await acquireFilesystemLock({ + filepath: normalized, + timeout, + staleAfter, + startTime, + }) + + return async () => { + fileLocks.delete(normalized) + releaseFn!() + await releaseFilesystem() + } +} diff --git a/packages/opencode/src/config/persist.ts b/packages/opencode/src/config/persist.ts new file mode 100644 index 00000000000..066b88b67f3 --- /dev/null +++ b/packages/opencode/src/config/persist.ts @@ -0,0 +1,183 @@ +import path from "path" +import fs from "fs/promises" +import { mergeDeep } from "remeda" +import { Config } from "./config" +import { acquireLock } from "./lock" +import { createBackup, restoreBackup } from "./backup" +import { writeConfigFile, writeFileAtomically } from "./write" +import { computeDiff, type ConfigDiff } from "./diff" +import { ConfigUpdateError, ConfigValidationError, ConfigWriteError } from "./error" +import { Instance } from "@/project/instance" +import { State } from "@/project/state" +import { resolveGlobalFile } from "./global-file" +import { Log } from "@/util/log" +import { parse as parseJsonc } from "jsonc-parser" +import z from "zod" +import { isConfigHotReloadEnabled } from "./hot-reload" + +const log = Log.create({ service: "config.persist" }) + +async function determineTargetFile(scope: "project" | "global", directory: string): Promise { + if (scope === "global") { + return resolveGlobalFile() + } + + const candidates = [ + path.join(directory, ".opencode", "opencode.jsonc"), + path.join(directory, ".opencode", "opencode.json"), + path.join(directory, "opencode.jsonc"), + path.join(directory, "opencode.json"), + ] + + for (const candidate of candidates) { + if (await Bun.file(candidate).exists()) { + return candidate + } + } + + const defaultPath = path.join(directory, ".opencode", "opencode.jsonc") + await fs.mkdir(path.dirname(defaultPath), { recursive: true }) + return defaultPath +} + +async function loadFileContent(filepath: string): Promise { + if (!(await Bun.file(filepath).exists())) { + return null + } + + return Bun.file(filepath).text() +} + +function normalizeConfig(config: Config.Info): Config.Info { + return { + $schema: config.$schema || "https://opencode.ai/schema/config.json", + ...config, + agent: config.agent || {}, + mode: config.mode || {}, + plugin: config.plugin || [], + } +} + +export async function update(input: { scope: "project" | "global"; update: Config.Info; directory: string }): Promise<{ + before: Config.Info + after: Config.Info + diff: ConfigDiff + diffForPublish: ConfigDiff + filepath: string +}> { + const filepath = await determineTargetFile(input.scope, input.directory) + const release = await acquireLock(filepath) + + log.info("config.update.start", { + scope: input.scope, + directory: input.directory, + filepath, + }) + + const beforeGlobal = input.scope === "global" ? await Config.global() : undefined + + try { + const backupPath = await createBackup(filepath) + + try { + const before = await Config.get() + + const existingContent = await loadFileContent(filepath) + const fileContent = existingContent ? parseJsonc(existingContent) : {} + const previousParsed = existingContent ? Config.Info.safeParse(fileContent) : undefined + const previousNormalized = previousParsed?.success ? normalizeConfig(previousParsed.data) : undefined + + const merged = mergeDeep(fileContent, input.update) + + const validated = Config.Info.parse(merged) + + const normalized = normalizeConfig(validated) + const writerDiff = previousNormalized ? computeDiff(previousNormalized, normalized) : undefined + + await writeConfigFile(filepath, normalized, existingContent, { + diff: writerDiff, + previous: previousNormalized, + }).catch((error) => { + log.error("JSONC write failed, attempting fallback", { + filepath, + error: String(error), + }) + + const content = JSON.stringify(normalized, null, 2) + "\n" + return writeFileAtomically(filepath, content) + }) + + const hotReloadEnabled = isConfigHotReloadEnabled() + if (hotReloadEnabled && input.scope === "global") { + await State.invalidate("config") + } + if (hotReloadEnabled && input.scope === "project") { + await Instance.invalidate("config") + } + + log.info("config.update.cacheInvalidated", { + scope: input.scope, + directory: input.directory, + filepath, + cacheInvalidated: hotReloadEnabled && input.scope === "global", + hotReloadEnabled, + }) + + const after = hotReloadEnabled ? await Config.get() : await Config.readFreshConfig() + const afterGlobal = input.scope === "global" ? await Config.global() : undefined + + const diff = computeDiff(before, after) + const diffForPublish = input.scope === "global" ? computeDiff(beforeGlobal!, afterGlobal!) : diff + + if (await Bun.file(backupPath).exists()) { + await fs.unlink(backupPath) + } + + log.info("config.update.persisted", { + scope: input.scope, + directory: input.directory, + filepath, + }) + + return { before, after, diff, diffForPublish, filepath } + } catch (error) { + if (await Bun.file(backupPath).exists()) { + await restoreBackup(backupPath, filepath).catch((restoreError) => { + log.error("Failed to restore backup", { + backupPath, + filepath, + error: String(restoreError), + }) + throw new ConfigWriteError({ + filepath, + operation: "restore", + cause: restoreError, + }) + }) + } + + if (error instanceof z.ZodError) { + const errors = error.issues.map((e: z.ZodIssue) => ({ + field: e.path.join("."), + message: e.message, + expected: "expected" in e ? String((e as any).expected) : undefined, + received: JSON.stringify("received" in e ? (e as any).received : undefined), + })) + + throw new ConfigValidationError({ filepath, errors }) + } + + throw new ConfigUpdateError( + { + filepath, + scope: input.scope, + directory: input.directory, + cause: error, + }, + { cause: error instanceof Error ? error : undefined }, + ) + } + } finally { + await release() + } +} diff --git a/packages/opencode/src/config/write.ts b/packages/opencode/src/config/write.ts new file mode 100644 index 00000000000..3fb6e5e2f21 --- /dev/null +++ b/packages/opencode/src/config/write.ts @@ -0,0 +1,295 @@ +import path from "path" +import fs from "fs/promises" +import { constants } from "fs" +import { randomUUID } from "crypto" +import { + modify, + applyEdits, + type ModificationOptions, + parse as parseJsonc, + type ParseError, + printParseErrorCode, +} from "jsonc-parser" +import { Log } from "@/util/log" +import type { Config } from "./config" +import type { ConfigDiff } from "./diff" +import { isDeepEqual } from "remeda" + +const log = Log.create({ service: "config.write" }) + +interface WriteConfigOptions { + diff?: ConfigDiff + previous?: Config.Info +} + +export async function writeConfigFile( + filepath: string, + newConfig: Config.Info, + existingContent: string | null, + options?: WriteConfigOptions, +): Promise { + const file = Bun.file(filepath) + const isJsonc = filepath.endsWith(".jsonc") || filepath.endsWith(".json") + + if (!existingContent || !(await file.exists())) { + const content = JSON.stringify(newConfig, null, 2) + "\n" + await writeFileAtomically(filepath, content) + return + } + + if (isJsonc) { + const updated = applyIncrementalUpdates(existingContent, newConfig, options) + validateJsonc(updated) + await writeFileAtomically(filepath, updated) + return + } + + const content = JSON.stringify(newConfig, null, 2) + "\n" + await writeFileAtomically(filepath, content) +} + +type UpdateInstruction = { path: (string | number)[]; value: unknown } +type UnknownRecord = Record +const nestedRecordKeys = new Set([ + "provider", + "mcp", + "agent", + "command", + "permission", + "formatter", + "lsp", + "tools", + "mode", +]) +const diffKeyToConfigKey: Record = { + provider: ["provider"], + mcp: ["mcp"], + lsp: ["lsp"], + formatter: ["formatter"], + watcher: ["watcher"], + plugin: ["plugin"], + agent: ["agent"], + command: ["command"], + permission: ["permission"], + tools: ["tools"], + instructions: ["instructions"], + share: ["share"], + autoshare: ["autoshare"], + theme: ["theme"], + model: ["model"], + small_model: ["small_model"], + disabled_providers: ["disabled_providers"], +} + +function applyIncrementalUpdates(content: string, newConfig: Config.Info, options?: WriteConfigOptions) { + const formattingOptions: ModificationOptions = { + formattingOptions: { + tabSize: 2, + insertSpaces: true, + eol: "\n", + }, + } + + let currentContent = content + + if (!options?.previous) { + for (const [key, value] of Object.entries(newConfig)) { + const edits = modify(currentContent, [key], value, formattingOptions) + currentContent = applyEdits(currentContent, edits) + } + return currentContent + } + + const instructions = buildUpdateInstructions(newConfig, options.previous, options.diff) + + if (instructions.length === 0) { + return currentContent + } + + for (const instruction of instructions) { + const edits = modify(currentContent, instruction.path, instruction.value, formattingOptions) + currentContent = applyEdits(currentContent, edits) + } + + return currentContent +} + +function buildUpdateInstructions( + newConfig: Config.Info, + previous: Config.Info, + diff?: ConfigDiff, +): UpdateInstruction[] { + const updateKeys = new Set() + if (diff) { + for (const [diffKey, configKeys] of Object.entries(diffKeyToConfigKey)) { + const flag = diff[diffKey as keyof ConfigDiff] + if (!flag) continue + for (const configKey of configKeys) { + updateKeys.add(configKey) + } + } + } + + const allKeys = new Set([...Object.keys(previous ?? {}), ...Object.keys(newConfig)]) + for (const key of allKeys) { + if (updateKeys.has(key)) continue + const prevValue = (previous as UnknownRecord)[key] + const nextValue = (newConfig as UnknownRecord)[key] + if (!isDeepEqual(prevValue, nextValue)) { + updateKeys.add(key) + } + } + + const instructions: UpdateInstruction[] = [] + for (const key of updateKeys) { + const nextHasKey = hasOwn(newConfig, key) + const prevValue = (previous as UnknownRecord)[key] + const nextValue = nextHasKey ? (newConfig as UnknownRecord)[key] : undefined + + if (!nextHasKey) { + instructions.push({ path: [key], value: undefined }) + continue + } + + if (nextValue === undefined) { + instructions.push({ path: [key], value: undefined }) + continue + } + + if (shouldUseNestedUpdates(key, prevValue, nextValue)) { + const nestedInstructions = buildNestedInstructions( + key, + prevValue as UnknownRecord | undefined, + nextValue as UnknownRecord | undefined, + diff, + ) + instructions.push(...nestedInstructions) + continue + } + + instructions.push({ path: [key], value: nextValue }) + } + + return sortInstructions(instructions) +} + +function buildNestedInstructions( + key: string, + previousValue: Record | undefined, + nextValue: Record | undefined, + diff?: ConfigDiff, +): UpdateInstruction[] { + const instructions: UpdateInstruction[] = [] + if (!previousValue && !nextValue) { + return instructions + } + + const diffChildKeys = new Set() + if (key === "provider" && diff?.providerKeys) { + for (const bucket of Object.values(diff.providerKeys)) { + bucket.forEach((child) => diffChildKeys.add(child)) + } + } + if (key === "mcp" && diff?.mcpKeys) { + for (const bucket of Object.values(diff.mcpKeys)) { + bucket.forEach((child) => diffChildKeys.add(child)) + } + } + + const previousKeys = Object.keys(previousValue ?? {}) + const nextKeys = Object.keys(nextValue ?? {}) + for (const name of [...previousKeys, ...nextKeys]) { + diffChildKeys.add(name) + } + + for (const childKey of diffChildKeys) { + const nextHasKey = hasOwn(nextValue, childKey) + const prevChild = previousValue ? (previousValue as UnknownRecord)[childKey] : undefined + if (!nextHasKey) { + if (typeof prevChild !== "undefined") { + instructions.push({ path: [key, childKey], value: undefined }) + } + continue + } + const nextChild = (nextValue as UnknownRecord)[childKey] + if (!isDeepEqual(prevChild, nextChild)) { + instructions.push({ path: [key, childKey], value: nextChild }) + } + } + + return instructions +} + +function shouldUseNestedUpdates(key: string, previousValue: unknown, nextValue: unknown) { + if (!nestedRecordKeys.has(key)) return false + if (typeof previousValue !== "object" || previousValue === null) return false + if (typeof nextValue !== "object" || nextValue === null) return false + return true +} + +function hasOwn(value: unknown, key: string): boolean { + if (!value || typeof value !== "object") return false + return Object.prototype.hasOwnProperty.call(value, key) +} + +function sortInstructions(instructions: UpdateInstruction[]): UpdateInstruction[] { + return instructions.sort((a, b) => { + if (a.path.length !== b.path.length) { + return a.path.length - b.path.length + } + const aPath = a.path.join(".") + const bPath = b.path.join(".") + if (aPath === bPath) return 0 + return aPath < bPath ? -1 : 1 + }) +} + +function validateJsonc(content: string) { + const errors: ParseError[] = [] + parseJsonc(content, errors, { allowTrailingComma: true }) + + if (errors.length === 0) { + return + } + + const details = errors + .map((error) => { + const code = printParseErrorCode(error.error) + return `${code} at ${error.offset}` + }) + .join("; ") + + throw new SyntaxError(`Invalid JSONC produced while persisting config: ${details}`) +} + +export async function writeFileAtomically(filepath: string, content: string): Promise { + const directory = path.dirname(filepath) + const tempName = `${path.basename(filepath)}.${randomUUID()}.tmp` + const tempPath = path.join(directory, tempName) + await fs.mkdir(directory, { recursive: true }) + const handle = await fs.open(tempPath, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC, 0o600) + await handle.writeFile(content, "utf8") + await handle.sync() + await handle.close() + await fs.rename(tempPath, filepath).catch(async (error) => { + await fs.unlink(tempPath).catch(() => {}) + throw error + }) + await syncDirectory(directory) +} + +async function syncDirectory(directory: string): Promise { + if (process.platform === "win32") return + const handle = await fs.open(directory, constants.O_RDONLY).catch((error: NodeJS.ErrnoException) => { + if (error?.code === "EISDIR") return + if (error?.code === "ENOENT") return + log.warn("directory sync skipped", { directory, error: String(error) }) + return + }) + if (!handle) return + + await handle.sync().catch((error: NodeJS.ErrnoException) => { + log.warn("directory sync failed", { directory, error: String(error) }) + }) + await handle.close() +} diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index aae7061c17a..5a38521c073 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -123,11 +123,11 @@ export namespace File { type Entry = { files: string[]; dirs: string[] } let cache: Entry = { files: [], dirs: [] } let fetching = false - const fn = async (result: Entry) => { - fetching = true + const fetchEntries = async () => { + const temp: Entry = { files: [], dirs: [] } const set = new Set() for await (const file of Ripgrep.files({ cwd: Instance.directory })) { - result.files.push(file) + temp.files.push(file) let current = file while (true) { const dir = path.dirname(current) @@ -136,21 +136,28 @@ export namespace File { current = dir if (set.has(dir)) continue set.add(dir) - result.dirs.push(dir + "/") + temp.dirs.push(dir + "/") } } - cache = result - fetching = false + cache = temp + } + const refresh = () => { + fetching = true + fetchEntries() + .catch((error) => { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return + log.error("failed to refresh files", { error }) + }) + .finally(() => { + fetching = false + }) } - fn(cache) + refresh() return { async files() { if (!fetching) { - fn({ - files: [], - dirs: [], - }) + refresh() } return cache }, diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index d5985b58266..7c60aa446c3 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -2,6 +2,7 @@ import z from "zod" import { Bus } from "../bus" import { Flag } from "../flag/flag" import { Instance } from "../project/instance" +import { State } from "../project/state" import { Log } from "../util/log" import { FileIgnore } from "./ignore" import { Config } from "../config/config" @@ -29,7 +30,9 @@ export namespace FileWatcher { return createWrapper(binding) as typeof import("@parcel/watcher") }) - const state = Instance.state( + const state = State.register( + "filewatcher", + () => Instance.directory, async () => { if (Instance.project.vcs !== "git") return {} log.info("init") diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index bab758030b9..21988389922 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -8,6 +8,7 @@ import * as Formatter from "./formatter" import { Config } from "../config/config" import { mergeDeep } from "remeda" import { Instance } from "../project/instance" +import { State } from "../project/state" export namespace Format { const log = Log.create({ service: "format" }) @@ -23,45 +24,47 @@ export namespace Format { }) export type Status = z.infer - const state = Instance.state(async () => { - const enabled: Record = {} - const cfg = await Config.get() + const state = State.register( + "format", + () => Instance.directory, + async () => { + const enabled: Record = {} + const cfg = await Config.get() - const formatters: Record = {} - if (cfg.formatter === false) { - log.info("all formatters are disabled") - return { - enabled, - formatters, + const formatters: Record = {} + if (cfg.formatter === false) { + log.info("all formatters are disabled") + return { + enabled, + formatters, + } } - } - for (const item of Object.values(Formatter)) { - formatters[item.name] = item - } - for (const [name, item] of Object.entries(cfg.formatter ?? {})) { - if (item.disabled) { - delete formatters[name] - continue + for (const item of Object.values(Formatter)) { + formatters[item.name] = item + } + for (const [name, item] of Object.entries(cfg.formatter ?? {})) { + if (item.disabled) { + delete formatters[name] + continue + } + const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, { + command: [], + extensions: [], + ...item, + }) + if (result.command.length === 0) continue + result.enabled = async () => true + result.name = name + formatters[name] = result } - const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, { - command: [], - extensions: [], - ...item, - }) - - if (result.command.length === 0) continue - - result.enabled = async () => true - result.name = name - formatters[name] = result - } - return { - enabled, - formatters, - } - }) + return { + enabled, + formatters, + } + }, + ) async function isEnabled(item: Formatter.Info) { const s = await state() diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 7ac2980c46a..98502659c08 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -8,6 +8,7 @@ import { Log } from "../util/log" declare global { const OPENCODE_VERSION: string const OPENCODE_CHANNEL: string + const OPENCODE_BASE_VERSION: string } export namespace Installation { @@ -160,8 +161,15 @@ export namespace Installation { export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local" export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local" + export const BASE_VERSION = typeof OPENCODE_BASE_VERSION === "string" ? OPENCODE_BASE_VERSION : VERSION export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}` + export function displayVersion() { + if (!isPreview()) return VERSION + if (BASE_VERSION === VERSION) return VERSION + return `${BASE_VERSION} (${VERSION})` + } + export async function latest() { const [major] = VERSION.split(".").map((x) => Number(x)) const channel = CHANNEL === "latest" ? `latest-${major}` : CHANNEL diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 6c082d0d7f2..499db9709e6 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -6,6 +6,7 @@ import z from "zod" import { Config } from "../config/config" import { spawn } from "child_process" import { Instance } from "../project/instance" +import { State } from "../project/state" import { Bus } from "../bus" export namespace LSP { @@ -58,7 +59,9 @@ export namespace LSP { }) export type DocumentSymbol = z.infer - const state = Instance.state( + const state = State.register( + "lsp", + () => Instance.directory, async () => { const clients: LSPClient.Info[] = [] const servers: Record = {} diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index a68a1716f0c..15965c6e5c8 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -8,6 +8,7 @@ import { Log } from "../util/log" import { NamedError } from "@opencode-ai/util/error" import z from "zod/v4" import { Instance } from "../project/instance" +import { State } from "../project/state" import { withTimeout } from "@/util/timeout" export namespace MCP { @@ -53,7 +54,9 @@ export namespace MCP { export type Status = z.infer type MCPClient = Awaited> - const state = Instance.state( + const state = State.register( + "mcp", + () => Instance.directory, async () => { const cfg = await Config.get() const config = cfg.mcp ?? {} diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 32dbd5a0370..d534046e5cb 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -4,6 +4,7 @@ import { Log } from "../util/log" import { Identifier } from "../id/id" import { Plugin } from "../plugin" import { Instance } from "../project/instance" +import { State } from "../project/state" import { Wildcard } from "../util/wildcard" export namespace Permission { @@ -49,7 +50,9 @@ export namespace Permission { ), } - const state = Instance.state( + const state = State.register( + "permission", + () => Instance.directory, () => { const pending: { [sessionID: string]: { diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 7d1f50ec8b2..2dbf6565b23 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -6,51 +6,65 @@ import { createOpencodeClient } from "@opencode-ai/sdk" import { Server } from "../server/server" import { BunProc } from "../bun" import { Instance } from "../project/instance" +import { State } from "../project/state" import { Flag } from "../flag/flag" export namespace Plugin { const log = Log.create({ service: "plugin" }) - const state = Instance.state(async () => { - const client = createOpencodeClient({ - baseUrl: "http://localhost:4096", - // @ts-ignore - fetch type incompatibility - fetch: async (...args) => Server.App().fetch(...args), - }) - const config = await Config.get() - const hooks = [] - const input: PluginInput = { - client, - project: Instance.project, - worktree: Instance.worktree, - directory: Instance.directory, - $: Bun.$, - } - const plugins = [...(config.plugin ?? [])] - if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) { - plugins.push("opencode-copilot-auth@0.0.7") - plugins.push("opencode-anthropic-auth@0.0.2") - } - for (let plugin of plugins) { - log.info("loading plugin", { path: plugin }) - if (!plugin.startsWith("file://")) { - const lastAtIndex = plugin.lastIndexOf("@") - const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin - const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest" - plugin = await BunProc.install(pkg, version) + const state = State.register( + "plugin", + () => Instance.directory, + async () => { + const client = createOpencodeClient({ + baseUrl: "http://localhost:4096", + // @ts-ignore - fetch type incompatibility + fetch: async (...args) => Server.App().fetch(...args), + }) + const config = await Config.get() + const hooks = [] + const input: PluginInput = { + client, + project: Instance.project, + worktree: Instance.worktree, + directory: Instance.directory, + $: Bun.$, } - const mod = await import(plugin) - for (const [_name, fn] of Object.entries(mod)) { - const init = await fn(input) - hooks.push(init) + const plugins = [...(config.plugin ?? [])] + if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) { + plugins.push("opencode-copilot-auth@0.0.7") + plugins.push("opencode-anthropic-auth@0.0.2") + } + for (let plugin of plugins) { + log.info("loading plugin", { path: plugin }) + if (!plugin.startsWith("file://")) { + const lastAtIndex = plugin.lastIndexOf("@") + const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin + const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest" + plugin = await BunProc.install(pkg, version) + } + const mod = await import(plugin) + for (const [_name, fn] of Object.entries(mod)) { + const init = await fn(input) + hooks.push(init) + } } - } - return { - hooks, - input, - } - }) + return { + hooks, + input, + } + }, + async (state) => { + for (const hook of state.hooks) { + if ("cleanup" in hook && typeof hook.cleanup === "function") { + await (hook.cleanup as () => Promise)().catch((error: Error) => { + log.error("Plugin cleanup failed", { error }) + }) + } + } + }, + ) export async function trigger< Name extends Exclude, "auth" | "event" | "tool">, diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 5840c9768b1..9a354b22063 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -10,10 +10,12 @@ import { Bus } from "../bus" import { Command } from "../command" import { Instance } from "./instance" import { Log } from "@/util/log" +import { ConfigInvalidation } from "../config/invalidation" import { ShareNext } from "@/share/share-next" export async function InstanceBootstrap() { Log.Default.info("bootstrapping", { directory: Instance.directory }) + await ConfigInvalidation.setup() await Plugin.init() Share.init() ShareNext.init() diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 4defefa515d..ab6211ba562 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -48,6 +48,31 @@ export const Instance = { state(init: () => S, dispose?: (state: Awaited) => Promise): () => S { return State.create(() => Instance.directory, init, dispose) }, + async invalidate(name: string) { + await State.invalidate(name, Instance.directory) + }, + async forEach(fn: (directory: string) => Promise): Promise> { + const errors: Array<{ directory: string; error: Error }> = [] + + for (const [directory, contextPromise] of cache) { + const ctx = await contextPromise + await context + .provide(ctx, async () => { + await fn(directory) + }) + .catch((error) => { + errors.push({ + directory, + error: error instanceof Error ? error : new Error(String(error)), + }) + }) + } + + if (errors.length > 0) { + Log.Default.warn("some instances failed during forEach", { errors }) + } + return errors + }, async dispose() { Log.Default.info("disposing instance", { directory: Instance.directory }) await State.dispose(Instance.directory) diff --git a/packages/opencode/src/project/state.ts b/packages/opencode/src/project/state.ts index c1ac23c5d26..8c41b34a02d 100644 --- a/packages/opencode/src/project/state.ts +++ b/packages/opencode/src/project/state.ts @@ -6,8 +6,15 @@ export namespace State { dispose?: (state: any) => Promise } + interface NamedEntry { + key: string + init: any + dispose?: (state: any) => Promise + } + const log = Log.create({ service: "state" }) const recordsByKey = new Map>() + const namedRegistry = new Map>() export function create(root: () => string, init: () => S, dispose?: (state: Awaited) => Promise) { return () => { @@ -28,6 +35,106 @@ export namespace State { } } + export function register( + name: string, + root: () => string, + init: () => S, + dispose?: (state: Awaited) => Promise, + ) { + const getter = create(root, init, dispose) + + const wrappedGetter = () => { + const key = root() + let entries = namedRegistry.get(name) + if (!entries) { + entries = new Set() + namedRegistry.set(name, entries) + } + + const hasEntry = Array.from(entries).some((e) => e.key === key && e.init === init) + if (!hasEntry) { + entries.add({ + key, + init, + dispose, + }) + } + + return getter() + } + + return wrappedGetter + } + + /** + * Invalidates (disposes and removes) state entries registered under the given name. + * + * If the `name` ends with `:*`, it is treated as a wildcard pattern and all registered names + * that start with the given prefix (before the `:*`) will be invalidated. + * + * If a `key` is provided, only entries matching both the name and key will be invalidated. + * If `key` is omitted, all entries for the given name (or matching names, if using a wildcard) will be invalidated. + * + * @param {string} name - The registered name of the state to invalidate. Supports wildcard patterns (e.g., "foo:*"). + * @param {string} [key] - Optional key to further filter which state entries to invalidate. + * @returns {Promise} Resolves when all matching state entries have been invalidated. + * + * @example + * // Invalidate all state entries registered under "user" + * await State.invalidate("user"); + * + * // Invalidate only the state entry for "user" with a specific key + * await State.invalidate("user", "user:123"); + * + * // Invalidate all state entries for all names starting with "cache:" + * await State.invalidate("cache:*"); + */ + export async function invalidate(name: string, key?: string) { + const pattern = name.endsWith(":*") ? name.slice(0, -1) : null + if (pattern) { + const tasks: Promise[] = [] + for (const [registeredName] of namedRegistry) { + if (registeredName.startsWith(pattern)) { + tasks.push(invalidate(registeredName, key)) + } + } + await Promise.all(tasks) + return + } + + const entries = namedRegistry.get(name) + if (!entries) { + return + } + + log.info("invalidating state", { name, key: key ?? "all" }) + + const tasks: Promise[] = [] + for (const entry of entries) { + if (key && entry.key !== key) continue + + const keyRecords = recordsByKey.get(entry.key) + if (!keyRecords) continue + + const stateEntry = keyRecords.get(entry.init) + if (!stateEntry) continue + + if (stateEntry.dispose) { + const task = Promise.resolve(stateEntry.state) + .then((state) => stateEntry.dispose!(state)) + .catch((error) => { + log.error("Error while disposing state", { error, name, key: entry.key }) + }) + tasks.push(task) + } + + keyRecords.delete(entry.init) + } + + await Promise.all(tasks) + log.info("state invalidation completed", { name, key: key ?? "all" }) + } + export async function dispose(key: string) { const entries = recordsByKey.get(key) if (!entries) return @@ -58,6 +165,7 @@ export namespace State { tasks.push(task) } entries.clear() + recordsByKey.delete(key) await Promise.all(tasks) disposalFinished = true log.info("state disposal completed", { key }) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 1cf0312ea6f..e800face10a 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -10,6 +10,7 @@ import { ModelsDev } from "./models" import { NamedError } from "@opencode-ai/util/error" import { Auth } from "../auth" import { Instance } from "../project/instance" +import { State } from "../project/state" import { Flag } from "../flag/flag" import { iife } from "@/util/iife" @@ -260,272 +261,281 @@ export namespace Provider { }, } - const state = Instance.state(async () => { - using _ = log.time("state") - const config = await Config.get() - const database = await ModelsDev.get() - - const disabled = new Set(config.disabled_providers ?? []) - const enabled = config.enabled_providers ? new Set(config.enabled_providers) : null - - function isProviderAllowed(providerID: string): boolean { - if (enabled && !enabled.has(providerID)) return false - if (disabled.has(providerID)) return false - return true - } - - const providers: { - [providerID: string]: { - source: Source - info: ModelsDev.Provider - getModel?: (sdk: any, modelID: string, options?: Record) => Promise - options: Record - } - } = {} - const models = new Map< - string, - { - providerID: string - modelID: string - info: ModelsDev.Model - language: LanguageModel - npm?: string - } - >() - const sdk = new Map() - // Maps `${provider}/${key}` to the provider’s actual model ID for custom aliases. - const realIdByKey = new Map() - - log.info("init") - - function mergeProvider( - id: string, - options: Record, - source: Source, - getModel?: (sdk: any, modelID: string, options?: Record) => Promise, - ) { - const provider = providers[id] - if (!provider) { - const info = database[id] - if (!info) return - if (info.api && !options["baseURL"]) options["baseURL"] = info.api - providers[id] = { - source, - info, - options, - getModel, + const state = State.register( + "provider", + () => Instance.directory, + async () => { + using _ = log.time("state") + const config = await Config.get() + const database = await ModelsDev.get() + + const disabled = new Set(config.disabled_providers ?? []) + const enabled = config.enabled_providers ? new Set(config.enabled_providers) : null + + function isProviderAllowed(providerID: string): boolean { + if (enabled && !enabled.has(providerID)) return false + if (disabled.has(providerID)) return false + return true + } + + const providers: { + [providerID: string]: { + source: Source + info: ModelsDev.Provider + getModel?: (sdk: any, modelID: string, options?: Record) => Promise + options: Record + } + } = {} + const models = new Map< + string, + { + providerID: string + modelID: string + info: ModelsDev.Model + language: LanguageModel + npm?: string + } + >() + const sdk = new Map() + // Maps `${provider}/${key}` to the provider's actual model ID for custom aliases. + const realIdByKey = new Map() + + log.info("init") + + function mergeProvider( + id: string, + options: Record, + source: Source, + getModel?: (sdk: any, modelID: string, options?: Record) => Promise, + ) { + const provider = providers[id] + if (!provider) { + const info = database[id] + if (!info) return + if (info.api && !options["baseURL"]) options["baseURL"] = info.api + providers[id] = { + source, + info, + options, + getModel, + } + return + } + provider.options = mergeDeep(provider.options, options) + provider.source = source + provider.getModel = getModel ?? provider.getModel + } + + const configProviders = Object.entries(config.provider ?? {}) + + // Add GitHub Copilot Enterprise provider that inherits from GitHub Copilot + if (database["github-copilot"]) { + const githubCopilot = database["github-copilot"] + database["github-copilot-enterprise"] = { + ...githubCopilot, + id: "github-copilot-enterprise", + name: "GitHub Copilot Enterprise", + // Enterprise uses a different API endpoint - will be set dynamically based on auth + api: undefined, } - return - } - provider.options = mergeDeep(provider.options, options) - provider.source = source - provider.getModel = getModel ?? provider.getModel - } - - const configProviders = Object.entries(config.provider ?? {}) - - // Add GitHub Copilot Enterprise provider that inherits from GitHub Copilot - if (database["github-copilot"]) { - const githubCopilot = database["github-copilot"] - database["github-copilot-enterprise"] = { - ...githubCopilot, - id: "github-copilot-enterprise", - name: "GitHub Copilot Enterprise", - // Enterprise uses a different API endpoint - will be set dynamically based on auth - api: undefined, } - } - for (const [providerID, provider] of configProviders) { - const existing = database[providerID] - const parsed: ModelsDev.Provider = { - id: providerID, - npm: provider.npm ?? existing?.npm, - name: provider.name ?? existing?.name ?? providerID, - env: provider.env ?? existing?.env ?? [], - api: provider.api ?? existing?.api, - models: existing?.models ?? {}, - } + for (const [providerID, provider] of configProviders) { + const existing = database[providerID] + const parsed: ModelsDev.Provider = { + id: providerID, + npm: provider.npm ?? existing?.npm, + name: provider.name ?? existing?.name ?? providerID, + env: provider.env ?? existing?.env ?? [], + api: provider.api ?? existing?.api, + models: existing?.models ?? {}, + } - for (const [modelID, model] of Object.entries(provider.models ?? {})) { - const existing = parsed.models[model.id ?? modelID] - const name = iife(() => { - if (model.name) return model.name - if (model.id && model.id !== modelID) return modelID - return existing?.name ?? modelID - }) - const parsedModel: ModelsDev.Model = { - id: modelID, - name, - release_date: model.release_date ?? existing?.release_date, - attachment: model.attachment ?? existing?.attachment ?? false, - reasoning: model.reasoning ?? existing?.reasoning ?? false, - temperature: model.temperature ?? existing?.temperature ?? false, - tool_call: model.tool_call ?? existing?.tool_call ?? true, - cost: - !model.cost && !existing?.cost - ? { - input: 0, - output: 0, - cache_read: 0, - cache_write: 0, - } - : { - cache_read: 0, - cache_write: 0, - ...existing?.cost, - ...model.cost, - }, - options: { - ...existing?.options, - ...model.options, - }, - limit: model.limit ?? - existing?.limit ?? { - context: 0, - output: 0, - }, - modalities: model.modalities ?? - existing?.modalities ?? { - input: ["text"], - output: ["text"], + for (const [modelID, model] of Object.entries(provider.models ?? {})) { + const existing = parsed.models[model.id ?? modelID] + const name = iife(() => { + if (model.name) return model.name + if (model.id && model.id !== modelID) return modelID + return existing?.name ?? modelID + }) + const parsedModel: ModelsDev.Model = { + id: modelID, + name, + release_date: model.release_date ?? existing?.release_date, + attachment: model.attachment ?? existing?.attachment ?? false, + reasoning: model.reasoning ?? existing?.reasoning ?? false, + temperature: model.temperature ?? existing?.temperature ?? false, + tool_call: model.tool_call ?? existing?.tool_call ?? true, + cost: + !model.cost && !existing?.cost + ? { + input: 0, + output: 0, + cache_read: 0, + cache_write: 0, + } + : { + cache_read: 0, + cache_write: 0, + ...existing?.cost, + ...model.cost, + }, + options: { + ...existing?.options, + ...model.options, }, - headers: model.headers, - provider: model.provider ?? existing?.provider, - } - if (model.id && model.id !== modelID) { - realIdByKey.set(`${providerID}/${modelID}`, model.id) + limit: model.limit ?? + existing?.limit ?? { + context: 0, + output: 0, + }, + modalities: model.modalities ?? + existing?.modalities ?? { + input: ["text"], + output: ["text"], + }, + headers: model.headers, + provider: model.provider ?? existing?.provider, + } + if (model.id && model.id !== modelID) { + realIdByKey.set(`${providerID}/${modelID}`, model.id) + } + parsed.models[modelID] = parsedModel } - parsed.models[modelID] = parsedModel + database[providerID] = parsed + } + + // load env + for (const [providerID, provider] of Object.entries(database)) { + if (disabled.has(providerID)) continue + const apiKey = provider.env.map((item) => process.env[item]).at(0) + if (!apiKey) continue + mergeProvider( + providerID, + // only include apiKey if there's only one potential option + provider.env.length === 1 ? { apiKey } : {}, + "env", + ) } - database[providerID] = parsed - } - - // load env - for (const [providerID, provider] of Object.entries(database)) { - if (disabled.has(providerID)) continue - const apiKey = provider.env.map((item) => process.env[item]).at(0) - if (!apiKey) continue - mergeProvider( - providerID, - // only include apiKey if there's only one potential option - provider.env.length === 1 ? { apiKey } : {}, - "env", - ) - } - - // load apikeys - for (const [providerID, provider] of Object.entries(await Auth.all())) { - if (disabled.has(providerID)) continue - if (provider.type === "api") { - mergeProvider(providerID, { apiKey: provider.key }, "api") + // load apikeys + for (const [providerID, provider] of Object.entries(await Auth.all())) { + if (disabled.has(providerID)) continue + if (provider.type === "api") { + mergeProvider(providerID, { apiKey: provider.key }, "api") + } } - } - // load custom - for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) { - if (disabled.has(providerID)) continue - const result = await fn(database[providerID]) - if (result && (result.autoload || providers[providerID])) { - mergeProvider(providerID, result.options ?? {}, "custom", result.getModel) + // load custom + for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) { + if (disabled.has(providerID)) continue + const result = await fn(database[providerID]) + if (result && (result.autoload || providers[providerID])) { + mergeProvider(providerID, result.options ?? {}, "custom", result.getModel) + } } - } - for (const plugin of await Plugin.list()) { - if (!plugin.auth) continue - const providerID = plugin.auth.provider - if (disabled.has(providerID)) continue + for (const plugin of await Plugin.list()) { + if (!plugin.auth) continue + const providerID = plugin.auth.provider + if (disabled.has(providerID)) continue - // For github-copilot plugin, check if auth exists for either github-copilot or github-copilot-enterprise - let hasAuth = false - const auth = await Auth.get(providerID) - if (auth) hasAuth = true + // For github-copilot plugin, check if auth exists for either github-copilot or github-copilot-enterprise + let hasAuth = false + const auth = await Auth.get(providerID) + if (auth) hasAuth = true - // Special handling for github-copilot: also check for enterprise auth - if (providerID === "github-copilot" && !hasAuth) { - const enterpriseAuth = await Auth.get("github-copilot-enterprise") - if (enterpriseAuth) hasAuth = true - } + // Special handling for github-copilot: also check for enterprise auth + if (providerID === "github-copilot" && !hasAuth) { + const enterpriseAuth = await Auth.get("github-copilot-enterprise") + if (enterpriseAuth) hasAuth = true + } - if (!hasAuth) continue - if (!plugin.auth.loader) continue + if (!hasAuth) continue + if (!plugin.auth.loader) continue - // Load for the main provider if auth exists - if (auth) { - const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider]) - mergeProvider(plugin.auth.provider, options ?? {}, "custom") - } + // Load for the main provider if auth exists + if (auth) { + const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider]) + mergeProvider(plugin.auth.provider, options ?? {}, "custom") + } - // If this is github-copilot plugin, also register for github-copilot-enterprise if auth exists - if (providerID === "github-copilot") { - const enterpriseProviderID = "github-copilot-enterprise" - if (!disabled.has(enterpriseProviderID)) { - const enterpriseAuth = await Auth.get(enterpriseProviderID) - if (enterpriseAuth) { - const enterpriseOptions = await plugin.auth.loader( - () => Auth.get(enterpriseProviderID) as any, - database[enterpriseProviderID], - ) - mergeProvider(enterpriseProviderID, enterpriseOptions ?? {}, "custom") + // If this is github-copilot plugin, also register for github-copilot-enterprise if auth exists + if (providerID === "github-copilot") { + const enterpriseProviderID = "github-copilot-enterprise" + if (!disabled.has(enterpriseProviderID)) { + const enterpriseAuth = await Auth.get(enterpriseProviderID) + if (enterpriseAuth) { + const enterpriseOptions = await plugin.auth.loader( + () => Auth.get(enterpriseProviderID) as any, + database[enterpriseProviderID], + ) + mergeProvider(enterpriseProviderID, enterpriseOptions ?? {}, "custom") + } } } } - } - - // load config - for (const [providerID, provider] of configProviders) { - mergeProvider(providerID, provider.options ?? {}, "config") - } - for (const [providerID, provider] of Object.entries(providers)) { - if (!isProviderAllowed(providerID)) { - delete providers[providerID] - continue + // load config + for (const [providerID, provider] of configProviders) { + mergeProvider(providerID, provider.options ?? {}, "config") } - const configProvider = config.provider?.[providerID] - const filteredModels = Object.fromEntries( - Object.entries(provider.info.models) - // Filter out blacklisted models - .filter( - ([modelID]) => - modelID !== "gpt-5-chat-latest" && !(providerID === "openrouter" && modelID === "openai/gpt-5-chat"), - ) - // Filter out experimental models - .filter( - ([, model]) => - ((!model.experimental && model.status !== "alpha") || Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) && - model.status !== "deprecated", - ) - // Filter by provider's whitelist/blacklist from config - .filter(([modelID]) => { - if (!configProvider) return true - - return ( - (!configProvider.blacklist || !configProvider.blacklist.includes(modelID)) && - (!configProvider.whitelist || configProvider.whitelist.includes(modelID)) + for (const [providerID, provider] of Object.entries(providers)) { + if (!isProviderAllowed(providerID)) { + delete providers[providerID] + continue + } + + const configProvider = config.provider?.[providerID] + const filteredModels = Object.fromEntries( + Object.entries(provider.info.models) + // Filter out blacklisted models + .filter( + ([modelID]) => + modelID !== "gpt-5-chat-latest" && !(providerID === "openrouter" && modelID === "openai/gpt-5-chat"), ) - }), - ) + // Filter out experimental models + .filter( + ([, model]) => + ((!model.experimental && model.status !== "alpha") || Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) && + model.status !== "deprecated", + ) + // Filter by provider's whitelist/blacklist from config + .filter(([modelID]) => { + if (!configProvider) return true - provider.info.models = filteredModels + return ( + (!configProvider.blacklist || !configProvider.blacklist.includes(modelID)) && + (!configProvider.whitelist || configProvider.whitelist.includes(modelID)) + ) + }), + ) - if (Object.keys(provider.info.models).length === 0) { - delete providers[providerID] - continue - } + provider.info.models = filteredModels - log.info("found", { providerID, npm: provider.info.npm }) - } + if (Object.keys(provider.info.models).length === 0) { + delete providers[providerID] + continue + } - return { - models, - providers, - sdk, - realIdByKey, - } - }) + // TODO: set this in models.dev, not set due to breaking issues on older OC versions + // u have to set include usage to true w/ this provider, setting in models.dev would cause undefined issue when accessing usage in older versions + if (providerID === "openrouter") { + provider.info.npm = "@openrouter/ai-sdk-provider" + } + + log.info("found", { providerID, npm: provider.info.npm }) + } + + return { + models, + providers, + sdk, + realIdByKey, + } + }, + ) export async function list() { return state().then((state) => state.providers) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 65c635ee16f..bac75b1d80a 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -40,6 +40,7 @@ import type { ContentfulStatusCode } from "hono/utils/http-status" import { TuiEvent } from "@/cli/cmd/tui/event" import { Snapshot } from "@/snapshot" import { SessionSummary } from "@/session/summary" +import { isConfigHotReloadEnabled } from "../config/hot-reload" import { GlobalBus } from "@/bus/global" import { SessionStatus } from "@/session/status" import { ShareNext } from "@/share/share-next" @@ -82,7 +83,23 @@ function errors(...codes: number[]) { export namespace Server { const log = Log.create({ service: "server" }) + // Remember last config update sections per directory to enrich subsequent TUI toasts. + // Entries auto-expire after a short window. + const LastConfigUpdate: Map = new Map() + // Periodically clean up stale entries from LastConfigUpdate + setInterval(() => { + const now = Date.now() + for (const [dir, entry] of LastConfigUpdate.entries()) { + if (now - entry.at > 60_000) { + LastConfigUpdate.delete(dir) + } + } + }, 60_000) + + function rememberConfigUpdate(directory: string, scope: "project" | "global", sections: string[]) { + LastConfigUpdate.set(directory, { scope, sections, at: Date.now() }) + } export const Event = { Connected: Bus.event("server.connected", z.object({})), } @@ -234,8 +251,57 @@ export namespace Server { validator("json", Config.Info), async (c) => { const config = c.req.valid("json") - await Config.update(config) - return c.json(config) + const scope = (c.req.query("scope") as "project" | "global" | undefined) ?? "project" + const directory = Instance.directory + + const result = await Config.update({ + scope, + update: config, + directory, + }) + + const publishDiff = result.diffForPublish + const hotReloadEnabled = isConfigHotReloadEnabled() + const sections = Object.keys(publishDiff).filter((k) => (publishDiff as any)[k] === true) + // Remember sections for toast enrichment regardless of hot reload mode + rememberConfigUpdate(directory, scope, sections) + + if (hotReloadEnabled && scope === "project") { + await Bus.publish(Config.Event.Updated, { + scope, + directory, + refreshed: true, + before: result.before, + after: result.after, + diff: publishDiff, + }) + } + if (hotReloadEnabled && scope === "global") { + const publishErrors = await Instance.forEach(async (dir) => { + await Bus.publish(Config.Event.Updated, { + scope, + directory: dir, + refreshed: true, + before: result.before, + after: result.after, + diff: publishDiff, + }) + rememberConfigUpdate(dir, scope, sections) + }) + + if (publishErrors.length > 0) { + log.error("config.publish.failure", { scope, errors: publishErrors }) + const details = publishErrors + .map((failure) => { + const message = failure.error instanceof Error ? failure.error.message : String(failure.error) + return `${failure.directory}: ${message}` + }) + .join("; ") + throw new Error(`Failed to notify directories: ${details}`) + } + } + + return c.json(result.after) }, ) .get( @@ -1910,7 +1976,36 @@ export namespace Server { }), validator("json", TuiEvent.ToastShow.properties), async (c) => { - await Bus.publish(TuiEvent.ToastShow, c.req.valid("json")) + const payload = c.req.valid("json") + // Enrich config save toasts that lack detail, e.g. "Saved global config -> undefined". + try { + const directory = Instance.directory + const match = payload.message.match(/Saved (global|project) config/i) + if (match) { + const scope = (match[1] as string).toLowerCase() as "global" | "project" + const now = Date.now() + const isFresh = (ts: number) => now - ts < 10_000 + + let candidate = LastConfigUpdate.get(directory) + if (!candidate || !isFresh(candidate.at) || candidate.scope !== scope) { + // Fallback: find the freshest entry with the same scope + candidate = Array.from(LastConfigUpdate.values()) + .filter((e) => e.scope === scope && isFresh(e.at)) + .sort((a, b) => b.at - a.at)[0] + } + + if (candidate) { + const sectionText = candidate.sections.length > 0 ? candidate.sections.join(", ") : "no changes" + // Replace generic arrow-suffix if present, otherwise rebuild the message + if (/->\s*undefined$/i.test(payload.message)) { + payload.message = payload.message.replace(/->\s*undefined$/i, `-> ${sectionText}`) + } else { + payload.message = `Saved ${candidate.scope} config -> ${sectionText}` + } + } + } + } catch {} + await Bus.publish(TuiEvent.ToastShow, payload) return c.json(true) }, ) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 41571bcef3a..7d1e94484e4 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -99,6 +99,9 @@ export namespace SessionCompaction { }) { const model = await Provider.getModel(input.model.providerID, input.model.modelID) const system = [...SystemPrompt.compaction(model.providerID)] + const lastFinished = input.messages.find((m) => m.info.role === "assistant" && m.info.finish)?.info as + | MessageV2.Assistant + | undefined const msg = (await Session.updateMessage({ id: Identifier.ascending("message"), role: "assistant", @@ -122,6 +125,10 @@ export namespace SessionCompaction { time: { created: Date.now(), }, + outputEstimate: lastFinished?.outputEstimate, + reasoningEstimate: lastFinished?.reasoningEstimate, + contextEstimate: lastFinished?.contextEstimate, + sentEstimate: lastFinished?.sentEstimate, })) as MessageV2.Assistant const processor = SessionProcessor.create({ assistantMessage: msg, diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 1a9b08d125e..6f9439619b6 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -302,6 +302,8 @@ export namespace MessageV2 { }), system: z.string().optional(), tools: z.record(z.string(), z.boolean()).optional(), + sentEstimate: z.number().optional(), + contextEstimate: z.number().optional(), }).meta({ ref: "UserMessage", }) @@ -361,6 +363,10 @@ export namespace MessageV2 { write: z.number(), }), }), + outputEstimate: z.number().optional(), + reasoningEstimate: z.number().optional(), + contextEstimate: z.number().optional(), + sentEstimate: z.number().optional(), finish: z.string().optional(), }).meta({ ref: "AssistantMessage", diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 5b45dc14dda..47f3436f171 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -11,6 +11,7 @@ import { SessionSummary } from "./summary" import { Bus } from "@/bus" import { SessionRetry } from "./retry" import { SessionStatus } from "./status" +import { Token } from "@/util/token" export namespace SessionProcessor { const DOOM_LOOP_THRESHOLD = 3 @@ -40,6 +41,9 @@ export namespace SessionProcessor { }, async process(fn: () => StreamTextResult, never>) { log.info("process") + // Initialize from existing estimates (convert tokens to characters) to accumulate across multiple process() calls + let reasoningTotal = Token.toCharCount(input.assistantMessage.reasoningEstimate ?? 0) + let textTotal = Token.toCharCount(input.assistantMessage.outputEstimate ?? 0) while (true) { try { let currentText: MessageV2.TextPart | undefined @@ -75,7 +79,15 @@ export namespace SessionProcessor { const part = reasoningMap[value.id] part.text += value.text if (value.providerMetadata) part.metadata = value.providerMetadata - if (part.text) await Session.updatePart({ part, delta: value.text }) + if (part.text) { + const active = Object.values(reasoningMap).reduce((sum, p) => sum + p.text.length, 0) + const estimate = Token.toTokenEstimate(Math.max(0, reasoningTotal + active)) + if (input.assistantMessage.reasoningEstimate !== estimate) { + input.assistantMessage.reasoningEstimate = estimate + await Session.updateMessage(input.assistantMessage) + } + await Session.updatePart({ part, delta: value.text }) + } } break @@ -89,6 +101,7 @@ export namespace SessionProcessor { end: Date.now(), } if (value.providerMetadata) part.metadata = value.providerMetadata + reasoningTotal += part.text.length await Session.updatePart(part) delete reasoningMap[value.id] } @@ -248,6 +261,8 @@ export namespace SessionProcessor { input.assistantMessage.finish = value.finishReason input.assistantMessage.cost += usage.cost input.assistantMessage.tokens = usage.tokens + input.assistantMessage.contextEstimate = + usage.tokens.input + usage.tokens.cache.read + usage.tokens.cache.write await Session.updatePart({ id: Identifier.ascending("part"), reason: value.finishReason, @@ -297,11 +312,17 @@ export namespace SessionProcessor { if (currentText) { currentText.text += value.text if (value.providerMetadata) currentText.metadata = value.providerMetadata - if (currentText.text) + if (currentText.text) { + const estimate = Token.toTokenEstimate(Math.max(0, textTotal + currentText.text.length)) + if (input.assistantMessage.outputEstimate !== estimate) { + input.assistantMessage.outputEstimate = estimate + await Session.updateMessage(input.assistantMessage) + } await Session.updatePart({ part: currentText, delta: value.text, }) + } } break @@ -313,6 +334,7 @@ export namespace SessionProcessor { end: Date.now(), } if (value.providerMetadata) currentText.metadata = value.providerMetadata + textTotal += currentText.text.length await Session.updatePart(currentText) } currentText = undefined diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e6c64f96b5a..91363c655a1 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -48,6 +48,7 @@ import { fn } from "@/util/fn" import { SessionProcessor } from "./processor" import { TaskTool } from "@/tool/task" import { SessionStatus } from "./status" +import { Token } from "@/util/token" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -313,71 +314,50 @@ export namespace SessionPrompt { time: { created: Date.now(), }, + outputEstimate: lastFinished?.outputEstimate, + reasoningEstimate: lastFinished?.reasoningEstimate, + contextEstimate: lastFinished?.contextEstimate, + sentEstimate: (lastAssistant?.sentEstimate || 0) + (lastUser.sentEstimate || 0), })) as MessageV2.Assistant - let part = (await Session.updatePart({ + + const part: MessageV2.ToolPart = { + type: "tool", id: Identifier.ascending("part"), messageID: assistantMessage.id, - sessionID: assistantMessage.sessionID, - type: "tool", + sessionID, + tool: "task", callID: ulid(), - tool: TaskTool.id, state: { status: "running", - input: { - prompt: task.prompt, - description: task.description, - subagent_type: task.agent, - }, time: { start: Date.now(), }, - }, - })) as MessageV2.ToolPart - const result = await taskTool - .execute( - { + input: { prompt: task.prompt, description: task.description, subagent_type: task.agent, }, - { - agent: task.agent, - messageID: assistantMessage.id, - sessionID: sessionID, - abort, - async metadata(input) { - await Session.updatePart({ - ...part, - type: "tool", - state: { - ...part.state, - ...input, - }, - } satisfies MessageV2.ToolPart) - }, - }, - ) - .catch(() => {}) - assistantMessage.finish = "tool-calls" - assistantMessage.time.completed = Date.now() - await Session.updateMessage(assistantMessage) - if (result && part.state.status === "running") { - await Session.updatePart({ - ...part, - state: { - status: "completed", - input: part.state.input, - title: result.title, - metadata: result.metadata, - output: result.output, - attachments: result.attachments, - time: { - ...part.state.time, - end: Date.now(), - }, - }, - } satisfies MessageV2.ToolPart) + }, } + await Session.updatePart(part) + + const result = await taskTool.execute( + { + prompt: task.prompt, + description: task.description, + subagent_type: task.agent, + }, + { + sessionID, + abort, + agent: lastUser.agent, + messageID: assistantMessage.id, + callID: part.callID, + extra: { providerID: model.providerID, modelID: model.modelID }, + metadata: async () => {}, + }, + ) + if (!result) { await Session.updatePart({ ...part, @@ -435,6 +415,17 @@ export namespace SessionPrompt { messages: msgs, agent, }) + + // Calculate tokens for tool results from previous assistant that will be sent in this API call + // Reuse parts from already-loaded messages to avoid redundant query + let toolResultTokens = 0 + if (lastAssistant && step > 1) { + const assistantMessage = msgs.find((m) => m.info.id === lastAssistant.id) + if (assistantMessage) { + toolResultTokens = Token.calculateToolResultTokens(assistantMessage.parts) + } + } + const processor = SessionProcessor.create({ assistantMessage: (await Session.updateMessage({ id: Identifier.ascending("message"), @@ -458,6 +449,10 @@ export namespace SessionPrompt { created: Date.now(), }, sessionID, + outputEstimate: lastFinished?.outputEstimate, + reasoningEstimate: lastFinished?.reasoningEstimate, + contextEstimate: lastFinished?.contextEstimate, + sentEstimate: (lastAssistant?.sentEstimate || 0) + (lastUser.sentEstimate || 0) + toolResultTokens, })) as MessageV2.Assistant, sessionID: sessionID, model: model.info, @@ -1067,6 +1062,25 @@ export namespace SessionPrompt { }, ) + const userText = parts + .filter((p) => p.type === "text" && !(p as MessageV2.TextPart).synthetic) + .map((p) => (p as MessageV2.TextPart).text) + .join("") + + // Calculate user message tokens + let sentTokens = Token.estimate(userText) + + // Add tokens from tool results that will be sent with this message + // Tool results from the previous assistant message are included in the API request + const msgs = await MessageV2.filterCompacted(MessageV2.stream(input.sessionID)) + const lastAssistant = msgs.findLast((m) => m.info.role === "assistant") + if (lastAssistant) { + sentTokens += Token.calculateToolResultTokens(lastAssistant.parts) + } + + info.sentEstimate = sentTokens + info.contextEstimate = sentTokens + await Session.updateMessage(info) for (const part of parts) { await Session.updatePart(part) @@ -1136,6 +1150,8 @@ export namespace SessionPrompt { providerID: model.providerID, modelID: model.modelID, }, + sentEstimate: 0, + contextEstimate: 0, } await Session.updateMessage(userMsg) const userPart: MessageV2.Part = { @@ -1148,6 +1164,12 @@ export namespace SessionPrompt { } await Session.updatePart(userPart) + const msgs = await MessageV2.filterCompacted(MessageV2.stream(input.sessionID)) + const lastFinished = msgs.find((m) => m.info.role === "assistant" && m.info.finish)?.info as + | MessageV2.Assistant + | undefined + const lastAssistant = msgs.find((m) => m.info.role === "assistant")?.info as MessageV2.Assistant | undefined + const msg: MessageV2.Assistant = { id: Identifier.ascending("message"), sessionID: input.sessionID, @@ -1170,6 +1192,10 @@ export namespace SessionPrompt { }, modelID: model.modelID, providerID: model.providerID, + outputEstimate: lastFinished?.outputEstimate, + reasoningEstimate: lastFinished?.reasoningEstimate, + contextEstimate: lastFinished?.contextEstimate, + sentEstimate: (lastAssistant?.sentEstimate || 0) + (userMsg.sentEstimate || 0), } await Session.updateMessage(msg) const part: MessageV2.Part = { diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index a741e12be23..f32de4d82e0 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -14,6 +14,7 @@ import type { Agent } from "../agent/agent" import { Tool } from "./tool" import { Instance } from "../project/instance" import { Config } from "../config/config" +import { State } from "../project/state" import path from "path" import { type ToolDefinition } from "@opencode-ai/plugin" import z from "zod" @@ -23,34 +24,38 @@ import { CodeSearchTool } from "./codesearch" import { Flag } from "@/flag/flag" export namespace ToolRegistry { - export const state = Instance.state(async () => { - const custom = [] as Tool.Info[] - const glob = new Bun.Glob("tool/*.{js,ts}") + export const state = State.register( + "tool-registry", + () => Instance.directory, + async () => { + const custom = [] as Tool.Info[] + const glob = new Bun.Glob("tool/*.{js,ts}") - for (const dir of await Config.directories()) { - for await (const match of glob.scan({ - cwd: dir, - absolute: true, - followSymlinks: true, - dot: true, - })) { - const namespace = path.basename(match, path.extname(match)) - const mod = await import(match) - for (const [id, def] of Object.entries(mod)) { - custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def)) + for (const dir of await Config.directories()) { + for await (const match of glob.scan({ + cwd: dir, + absolute: true, + followSymlinks: true, + dot: true, + })) { + const namespace = path.basename(match, path.extname(match)) + const mod = await import(match) + for (const [id, def] of Object.entries(mod)) { + custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def)) + } } } - } - const plugins = await Plugin.list() - for (const plugin of plugins) { - for (const [id, def] of Object.entries(plugin.tool ?? {})) { - custom.push(fromPlugin(id, def)) + const plugins = await Plugin.list() + for (const plugin of plugins) { + for (const [id, def] of Object.entries(plugin.tool ?? {})) { + custom.push(fromPlugin(id, def)) + } } - } - return { custom } - }) + return { custom } + }, + ) function fromPlugin(id: string, def: ToolDefinition): Tool.Info { return { diff --git a/packages/opencode/src/util/token.ts b/packages/opencode/src/util/token.ts index cee5adc3771..58b33855c97 100644 --- a/packages/opencode/src/util/token.ts +++ b/packages/opencode/src/util/token.ts @@ -4,4 +4,46 @@ export namespace Token { export function estimate(input: string) { return Math.max(0, Math.round((input || "").length / CHARS_PER_TOKEN)) } + + /** + * Convert token estimate to character count + * Used when accumulating text across stream deltas + */ + export function toCharCount(tokenEstimate: number): number { + return tokenEstimate * CHARS_PER_TOKEN + } + + /** + * Convert character count to token estimate + * Used when converting accumulated text back to tokens + */ + export function toTokenEstimate(charCount: number): number { + return Math.round(charCount / CHARS_PER_TOKEN) + } + + /** + * Calculate tokens for tool results that will be sent to the API + * Includes tool input JSON, output (or compaction message), and errors + */ + export function calculateToolResultTokens(parts: Array<{ type: string; state?: any }>) { + let tokens = 0 + for (const part of parts) { + if (part.type === "tool") { + // Tool input is sent in both completed and error states + tokens += estimate(JSON.stringify(part.state.input)) + + if (part.state.status === "completed") { + // Tool result output - check if compacted + const output = part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output + tokens += estimate(output) + } + + if (part.state.status === "error") { + // Tool error text is sent back to the API + tokens += estimate(part.state.error) + } + } + } + return tokens + } } diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 2ff8c01cdb0..1c2e017b5c9 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1,11 +1,65 @@ import { test, expect } from "bun:test" import { Config } from "../../src/config/config" import { Instance } from "../../src/project/instance" +import { Global } from "../../src/global" import { tmpdir } from "../fixture/fixture" import path from "path" import fs from "fs/promises" import { pathToFileURL } from "url" +async function withHotReloadFlag(value: string | undefined, fn: () => Promise) { + const previous = process.env.OPENCODE_CONFIG_HOT_RELOAD + if (typeof value === "string") { + process.env.OPENCODE_CONFIG_HOT_RELOAD = value + } else { + delete process.env.OPENCODE_CONFIG_HOT_RELOAD + } + try { + return await fn() + } finally { + if (previous === undefined) { + delete process.env.OPENCODE_CONFIG_HOT_RELOAD + } else { + process.env.OPENCODE_CONFIG_HOT_RELOAD = previous + } + } +} + +function scopedPluginFixture() { + return tmpdir({ + init: async (dir) => { + const pluginDir = path.join(dir, "node_modules", "@scope", "plugin") + await fs.mkdir(pluginDir, { recursive: true }) + + await Bun.write( + path.join(dir, "package.json"), + JSON.stringify({ name: "config-fixture", version: "1.0.0", type: "module" }, null, 2), + ) + + await Bun.write( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: "@scope/plugin", + version: "1.0.0", + type: "module", + main: "./index.js", + }, + null, + 2, + ), + ) + + await Bun.write(path.join(pluginDir, "index.js"), "export default {}\n") + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin: ["@scope/plugin"] }, null, 2), + ) + }, + }) +} + test("loads config with defaults when no files exist", async () => { await using tmp = await tmpdir() await Instance.provide({ @@ -214,6 +268,34 @@ test("handles agent configuration", async () => { }) }) +test("preserves scoped plugin specifiers and resolves relative plugin paths", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const pluginDir = path.join(dir, "local-plugins") + await fs.mkdir(pluginDir, { recursive: true }) + const pluginFile = path.join(pluginDir, "custom.ts") + await Bun.write(pluginFile, "export default {}") + await Bun.write( + path.join(dir, "opencode.jsonc"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + plugin: ["@promethean-os/opencode-openai-codex-auth", "./local-plugins/custom.ts"], + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.plugin).toContain("@promethean-os/opencode-openai-codex-auth") + const pluginFileUrl = pathToFileURL(path.join(tmp.path, "local-plugins", "custom.ts")).href + expect(config.plugin).toContain(pluginFileUrl) + }, + }) +}) + test("handles command configuration", async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -333,9 +415,9 @@ test("updates config and writes to file", async () => { directory: tmp.path, fn: async () => { const newConfig = { model: "updated/model" } - await Config.update(newConfig as any) + const result = await Config.update({ update: newConfig as any }) - const writtenConfig = JSON.parse(await Bun.file(path.join(tmp.path, "config.json")).text()) + const writtenConfig = JSON.parse(await Bun.file(result.filepath).text()) expect(writtenConfig.model).toBe("updated/model") }, }) @@ -352,124 +434,97 @@ test("gets config directories", async () => { }) }) -test("resolves scoped npm plugins in config", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const pluginDir = path.join(dir, "node_modules", "@scope", "plugin") - await fs.mkdir(pluginDir, { recursive: true }) - - await Bun.write( - path.join(dir, "package.json"), - JSON.stringify({ name: "config-fixture", version: "1.0.0", type: "module" }, null, 2), - ) +test("does not rewrite scoped npm plugins even when hot reload is enabled", async () => { + await withHotReloadFlag("true", async () => { + await using tmp = await scopedPluginFixture() - await Bun.write( - path.join(pluginDir, "package.json"), - JSON.stringify( - { - name: "@scope/plugin", - version: "1.0.0", - type: "module", - main: "./index.js", - }, - null, - 2, - ), - ) - - await Bun.write(path.join(pluginDir, "index.js"), "export default {}\n") - - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin: ["@scope/plugin"] }, null, 2), - ) - }, + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.plugin).toContain("@scope/plugin") + }, + }) }) +}) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await Config.get() - const pluginEntries = config.plugin ?? [] - - const baseUrl = pathToFileURL(path.join(tmp.path, "opencode.json")).href - const expected = import.meta.resolve("@scope/plugin", baseUrl) - - expect(pluginEntries.includes(expected)).toBe(true) +test("keeps scoped npm plugin identifiers when hot reload is disabled", async () => { + await withHotReloadFlag(undefined, async () => { + await using tmp = await scopedPluginFixture() - const scopedEntry = pluginEntries.find((entry) => entry === expected) - expect(scopedEntry).toBeDefined() - expect(scopedEntry?.includes("/node_modules/@scope/plugin/")).toBe(true) - }, + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.plugin).toContain("@scope/plugin") + }, + }) }) }) -test("merges plugin arrays from global and local configs", async () => { - await using tmp = await tmpdir({ +test("appends plugins discovered from directories after merging config files", async () => { + await using globalTmp = await tmpdir({ init: async (dir) => { - // Create a nested project structure with local .opencode config - const projectDir = path.join(dir, "project") - const opencodeDir = path.join(projectDir, ".opencode") - await fs.mkdir(opencodeDir, { recursive: true }) - - // Global config with plugins + await fs.mkdir(path.join(dir, "plugin"), { recursive: true }) + await Bun.write(path.join(dir, "plugin", "custom.ts"), "export const plugin = {}") await Bun.write( - path.join(dir, "opencode.json"), + path.join(dir, "opencode.jsonc"), JSON.stringify({ $schema: "https://opencode.ai/config.json", - plugin: ["global-plugin-1", "global-plugin-2"], + plugin: ["global-plugin"], }), ) + }, + }) - // Local .opencode config with different plugins + await using workspace = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".opencode"), { recursive: true }) await Bun.write( - path.join(opencodeDir, "opencode.json"), + path.join(dir, ".opencode", "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", - plugin: ["local-plugin-1"], + plugin: ["local-plugin"], }), ) }, }) - await Instance.provide({ - directory: path.join(tmp.path, "project"), - fn: async () => { - const config = await Config.get() - const plugins = config.plugin ?? [] - - // Should contain both global and local plugins - expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true) - expect(plugins.some((p) => p.includes("global-plugin-2"))).toBe(true) - expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true) - - // Should have all 3 plugins (not replaced, but merged) - const pluginNames = plugins.filter((p) => p.includes("global-plugin") || p.includes("local-plugin")) - expect(pluginNames.length).toBeGreaterThanOrEqual(3) - }, - }) + const previousGlobalConfig = Global.Path.config + ;(Global.Path as any).config = globalTmp.path + try { + await Instance.provide({ + directory: workspace.path, + fn: async () => { + const config = await Config.get() + const pluginEntries = config.plugin ?? [] + const pluginFile = `file://${path.join(globalTmp.path, "plugin", "custom.ts")}` + expect(pluginEntries).toEqual(["global-plugin", "local-plugin", pluginFile]) + }, + }) + } finally { + ;(Global.Path as any).config = previousGlobalConfig + } }) test("deduplicates duplicate plugins from global and local configs", async () => { - await using tmp = await tmpdir({ + await using globalTmp = await tmpdir({ init: async (dir) => { - // Create a nested project structure with local .opencode config - const projectDir = path.join(dir, "project") - const opencodeDir = path.join(projectDir, ".opencode") - await fs.mkdir(opencodeDir, { recursive: true }) - - // Global config with plugins await Bun.write( - path.join(dir, "opencode.json"), + path.join(dir, "opencode.jsonc"), JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin: ["duplicate-plugin", "global-plugin-1"], }), ) + }, + }) - // Local .opencode config with some overlapping plugins + await using workspace = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".opencode"), { recursive: true }) await Bun.write( - path.join(opencodeDir, "opencode.json"), + path.join(dir, ".opencode", "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin: ["duplicate-plugin", "local-plugin-1"], @@ -478,26 +533,18 @@ test("deduplicates duplicate plugins from global and local configs", async () => }, }) - await Instance.provide({ - directory: path.join(tmp.path, "project"), - fn: async () => { - const config = await Config.get() - const plugins = config.plugin ?? [] - - // Should contain all unique plugins - expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true) - expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true) - expect(plugins.some((p) => p.includes("duplicate-plugin"))).toBe(true) - - // Should deduplicate the duplicate plugin - const duplicatePlugins = plugins.filter((p) => p.includes("duplicate-plugin")) - expect(duplicatePlugins.length).toBe(1) - - // Should have exactly 3 unique plugins - const pluginNames = plugins.filter( - (p) => p.includes("global-plugin") || p.includes("local-plugin") || p.includes("duplicate-plugin"), - ) - expect(pluginNames.length).toBe(3) - }, - }) + const previousGlobalConfig = Global.Path.config + ;(Global.Path as any).config = globalTmp.path + try { + await Instance.provide({ + directory: workspace.path, + fn: async () => { + const config = await Config.get() + const plugins = config.plugin ?? [] + expect(plugins).toEqual(["duplicate-plugin", "global-plugin-1", "local-plugin-1"]) + }, + }) + } finally { + ;(Global.Path as any).config = previousGlobalConfig + } }) diff --git a/packages/opencode/test/config/hot-reload.test.ts b/packages/opencode/test/config/hot-reload.test.ts new file mode 100644 index 00000000000..77b608aa8aa --- /dev/null +++ b/packages/opencode/test/config/hot-reload.test.ts @@ -0,0 +1,368 @@ +import { test, expect } from "bun:test" +import os from "os" +import path from "path" +import fs from "fs/promises" +import { Config } from "../../src/config/config" +import { Instance } from "../../src/project/instance" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { Bus } from "../../src/bus" +import { Server } from "../../src/server/server" +import { Global } from "../../src/global" +import { ConfigInvalidation } from "../../src/config/invalidation" + +async function withFreshGlobalPath(fn: (globalRoot: string) => Promise) { + const originalGlobalConfig = Global.Path.config + const globalRoot = path.join(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-global-")), "config") + ;(Global.Path as any).config = globalRoot + await fs.mkdir(globalRoot, { recursive: true }) + try { + return await fn(globalRoot) + } finally { + ;(Global.Path as any).config = originalGlobalConfig + await fs.rm(globalRoot, { recursive: true, force: true }) + } +} + +async function createWorkspace(prefix?: string) { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix ?? "opencode-test-")) + await fs.mkdir(path.join(tmpDir, ".git"), { recursive: true }) + await fs.writeFile(path.join(tmpDir, ".git", "HEAD"), "ref: refs/heads/main") + return tmpDir +} + +async function patchConfig(directory: string, body: Record, scope: "project" | "global" = "project") { + const url = new URL("/config", "http://localhost") + url.searchParams.set("scope", scope) + url.searchParams.set("directory", directory) + + return Server.App().fetch( + new Request(url.toString(), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ) +} + +async function getConfig(directory: string) { + const url = new URL("/config", "http://localhost") + url.searchParams.set("directory", directory) + return Server.App().fetch( + new Request(url.toString(), { + method: "GET", + }), + ) +} + +async function subscribeWithContext(directory: string, callback: (event: any) => Promise | void) { + return Instance.provide({ + directory, + fn: async () => { + return Bus.subscribe(Config.Event.Updated, async (event) => { + const targetDirectory = event.properties.directory ?? process.cwd() + return Instance.provide({ + directory: targetDirectory, + fn: async () => { + await callback(event) + }, + }) + }) + }, + }) +} + +async function ensureInstance(directory: string) { + await Instance.provide({ + directory, + init: InstanceBootstrap, + fn: async () => { + await Config.get() + }, + }) +} + +async function cleanup(directories: string[]) { + await Instance.disposeAll() + for (const dir of directories) { + await fs.rm(dir, { recursive: true, force: true }) + } +} + +await Instance.disposeAll() + +test("config hot reload updates without full dispose", async () => { + process.env.OPENCODE_CONFIG_HOT_RELOAD = "true" + const directory = await createWorkspace("hot-reload-") + try { + await withFreshGlobalPath(async () => { + await Instance.provide({ + directory, + fn: async () => { + const before = await Config.get() + expect(before.model).toBeUndefined() + + const result = await Config.update({ + scope: "project", + update: { model: "anthropic/claude-3-5-sonnet" }, + directory, + }) + + expect(result.after.model).toBe("anthropic/claude-3-5-sonnet") + + const configPath = path.join(directory, ".opencode", "opencode.jsonc") + expect(await Bun.file(configPath).exists()).toBe(true) + + const content = await Bun.file(configPath).text() + expect(content).toContain("anthropic/claude-3-5-sonnet") + expect(result.filepath).toBe(configPath) + }, + }) + }) + } finally { + delete process.env.OPENCODE_CONFIG_HOT_RELOAD + await cleanup([directory]) + } +}) + +test("config hot reload with feature flag disabled uses full dispose", async () => { + delete process.env.OPENCODE_CONFIG_HOT_RELOAD + const directory = await createWorkspace("hot-reload-disabled-") + try { + await withFreshGlobalPath(async () => { + await Instance.provide({ + directory, + fn: async () => { + const result = await Config.update({ + scope: "project", + update: { model: "anthropic/claude-3-5-sonnet" }, + directory, + }) + + expect(result.after.model).toBe("anthropic/claude-3-5-sonnet") + }, + }) + }) + } finally { + await cleanup([directory]) + } +}) + +test("GET /config returns cached view when hot reload is disabled", async () => { + delete process.env.OPENCODE_CONFIG_HOT_RELOAD + const directory = await createWorkspace("hot-reload-get-disabled-") + try { + await withFreshGlobalPath(async () => { + await ensureInstance(directory) + + const patchResponse = await patchConfig(directory, { model: "cached-model" }, "project") + expect(patchResponse.status).toBe(200) + + const response = await getConfig(directory) + expect(response.status).toBe(200) + const body = await response.json() + expect(body.model).toBeUndefined() + + const configPath = path.join(directory, ".opencode", "opencode.jsonc") + const fileContent = await Bun.file(configPath).text() + expect(fileContent).toContain("cached-model") + }) + } finally { + await cleanup([directory]) + } +}) + +test("global updates propagate despite local overrides", async () => { + process.env.OPENCODE_CONFIG_HOT_RELOAD = "true" + const writer = await createWorkspace("global-writer-") + const observer = await createWorkspace("global-observer-") + try { + await withFreshGlobalPath(async () => { + await fs.mkdir(path.join(writer, ".opencode"), { recursive: true }) + await fs.writeFile(path.join(writer, ".opencode", "opencode.jsonc"), JSON.stringify({ model: "local-model" })) + + await ensureInstance(writer) + await ensureInstance(observer) + + const response = await patchConfig(writer, { model: "global-model" }, "global") + expect(response.status).toBe(200) + + await Instance.provide({ + directory: observer, + fn: async () => { + const config = await Config.get() + expect(config.model).toBe("global-model") + }, + }) + }) + } finally { + delete process.env.OPENCODE_CONFIG_HOT_RELOAD + await cleanup([writer, observer]) + } +}) + +test("custom XDG_CONFIG_HOME is honored for global updates", async () => { + process.env.OPENCODE_CONFIG_HOT_RELOAD = "true" + const workspace = await createWorkspace("xdg-config-") + try { + await withFreshGlobalPath(async () => { + const xdgBase = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-xdg-")) + const customConfigRoot = path.join(xdgBase, "opencode") + const previousConfigPath = Global.Path.config + try { + ;(Global.Path as any).config = customConfigRoot + await fs.mkdir(customConfigRoot, { recursive: true }) + await ensureInstance(workspace) + + const response = await patchConfig(workspace, { model: "xdg-model" }, "global") + expect(response.status).toBe(200) + + const fileContent = await Bun.file(path.join(customConfigRoot, "opencode.jsonc")).text() + expect(fileContent).toContain("xdg-model") + + await Instance.provide({ + directory: workspace, + fn: async () => { + const config = await Config.get() + expect(config.model).toBe("xdg-model") + }, + }) + } finally { + ;(Global.Path as any).config = previousConfigPath + await fs.rm(xdgBase, { recursive: true, force: true }) + } + }) + } finally { + delete process.env.OPENCODE_CONFIG_HOT_RELOAD + await cleanup([workspace]) + } +}) + +test("event subscriber sees refreshed config before targeted invalidations", async () => { + process.env.OPENCODE_CONFIG_HOT_RELOAD = "true" + const directory = await createWorkspace("event-subscriber-") + try { + await withFreshGlobalPath(async () => { + await ensureInstance(directory) + + const response = await patchConfig(directory, { model: "event-model" }, "global") + expect(response.status).toBe(200) + + await Instance.provide({ + directory, + fn: async () => { + const config = await Config.get() + expect(config.model).toBe("event-model") + }, + }) + }) + } finally { + delete process.env.OPENCODE_CONFIG_HOT_RELOAD + await cleanup([directory]) + } +}) + +test("global fan-out surfaces aggregated publish errors", async () => { + process.env.OPENCODE_CONFIG_HOT_RELOAD = "true" + const sender = await createWorkspace("fanout-sender-") + const target = await createWorkspace("fanout-target-") + try { + await withFreshGlobalPath(async () => { + await ensureInstance(sender) + await ensureInstance(target) + + const unsub = await subscribeWithContext(target, (event) => { + if (event.properties.directory === target) { + throw new Error("publish failure") + } + }) + + const response = await patchConfig(sender, { model: "fanout-model" }, "global") + expect(response.status).toBe(500) + + const json = await response.json() + const message = String((json && (json.message ?? json.data?.message)) ?? "") + expect(message).toContain("Failed to notify directories") + + await Instance.provide({ + directory: target, + fn: async () => { + const config = await Config.get() + expect(config.model).toBe("fanout-model") + }, + }) + + unsub() + }) + } finally { + delete process.env.OPENCODE_CONFIG_HOT_RELOAD + await cleanup([sender, target]) + } +}) + +test("project updates remain scoped to the initiator", async () => { + process.env.OPENCODE_CONFIG_HOT_RELOAD = "true" + const writer = await createWorkspace("project-writer-") + const observer = await createWorkspace("project-observer-") + try { + await withFreshGlobalPath(async () => { + await ensureInstance(writer) + await ensureInstance(observer) + + await patchConfig(writer, { model: "global-model" }, "global") + + const response = await patchConfig(writer, { model: "project-model" }, "project") + expect(response.status).toBe(200) + + await Instance.provide({ + directory: writer, + fn: async () => { + const config = await Config.get() + expect(config.model).toBe("project-model") + }, + }) + + await Instance.provide({ + directory: observer, + fn: async () => { + const config = await Config.get() + expect(config.model).toBe("global-model") + }, + }) + }) + } finally { + delete process.env.OPENCODE_CONFIG_HOT_RELOAD + await cleanup([writer, observer]) + } +}) + +test("theme-only global updates avoid unrelated invalidations", async () => { + process.env.OPENCODE_CONFIG_HOT_RELOAD = "true" + const workspace = await createWorkspace("theme-only-") + try { + await withFreshGlobalPath(async () => { + await ensureInstance(workspace) + const invalidations: string[] = [] + const originalInvalidate = Instance.invalidate + ;(Instance as any).invalidate = async (name: string) => { + invalidations.push(name) + await originalInvalidate(name) + } + + try { + await ConfigInvalidation.apply({ + scope: "global", + directory: workspace, + diff: { theme: true }, + }) + } finally { + ;(Instance as any).invalidate = originalInvalidate + } + + const nonConfigInvalidations = invalidations.filter((name) => name !== "config") + expect(nonConfigInvalidations).toEqual(["theme"]) + }) + } finally { + delete process.env.OPENCODE_CONFIG_HOT_RELOAD + await cleanup([workspace]) + } +}) diff --git a/packages/opencode/test/config/write.test.ts b/packages/opencode/test/config/write.test.ts new file mode 100644 index 00000000000..61a168795bc --- /dev/null +++ b/packages/opencode/test/config/write.test.ts @@ -0,0 +1,75 @@ +import { test, expect } from "bun:test" +import os from "os" +import path from "path" +import fs from "fs/promises" +import { parse as parseJsonc, type ParseError } from "jsonc-parser" +import { writeConfigFile } from "../../src/config/write" +import { Config } from "../../src/config/config" + +test("writeConfigFile preserves JSONC comments without triggering fallback", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-jsonc-")) + const filepath = path.join(dir, "opencode.jsonc") + const original = `{ + // keep me + "model": "before" +} +` + await Bun.write(filepath, original) + + try { + await expect( + writeConfigFile( + filepath, + { + model: "after", + }, + original, + ), + ).resolves.toBeUndefined() + + const updated = await Bun.file(filepath).text() + expect(updated).toContain("// keep me") + expect(updated).toContain(`"model": "after"`) + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } +}) + +test("writeConfigFile incremental edits keep JSONC valid", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-jsonc-incremental-")) + const filepath = path.join(dir, "opencode.jsonc") + const original = `{ + // settings + "model": "anthropic/old", + "theme": "light", + "agent": { + "build": { + "model": "anthropic/old" + } + } +} +` + await Bun.write(filepath, original) + + const nextConfig = Config.Info.parse({ + $schema: "https://opencode.ai/schema/config.json", + model: "anthropic/new", + theme: "dark", + agent: { + build: { model: "anthropic/new" }, + plan: { model: "anthropic/new" }, + }, + }) + + try { + await writeConfigFile(filepath, nextConfig, original) + const updated = await Bun.file(filepath).text() + const errors: ParseError[] = [] + parseJsonc(updated, errors, { allowTrailingComma: true }) + expect(errors.length).toBe(0) + expect(updated).toContain("// settings") + expect(updated).toContain(`"theme": "dark"`) + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } +}) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 7e55a26ab7f..e7f3b406476 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -24,4 +24,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -} \ No newline at end of file +} diff --git a/packages/sdk/go/config.go b/packages/sdk/go/config.go index 02460fb5df9..87663b96262 100644 --- a/packages/sdk/go/config.go +++ b/packages/sdk/go/config.go @@ -1935,6 +1935,10 @@ type KeybindsConfig struct { ModelCycleRecent string `json:"model_cycle_recent"` // Previous recent model ModelCycleRecentReverse string `json:"model_cycle_recent_reverse"` + // Next favorite model + ModelCycleFavorite string `json:"model_cycle_favorite"` + // Previous favorite model + ModelCycleFavoriteReverse string `json:"model_cycle_favorite_reverse"` // List available models ModelList string `json:"model_list"` // Create/update AGENTS.md @@ -2008,6 +2012,8 @@ type keybindsConfigJSON struct { MessagesUndo apijson.Field ModelCycleRecent apijson.Field ModelCycleRecentReverse apijson.Field + ModelCycleFavorite apijson.Field + ModelCycleFavoriteReverse apijson.Field ModelList apijson.Field ProjectInit apijson.Field SessionChildCycle apijson.Field diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 2972b63e389..1173d9dfafe 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -26,4 +26,4 @@ "publishConfig": { "directory": "dist" } -} \ No newline at end of file +} diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 2de8ca2f1cf..c9a7cfd690a 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -14,6 +14,18 @@ export type EventInstallationUpdateAvailable = { } } +export type EventConfigUpdated = { + type: "config.updated" + properties: { + scope: "project" | "global" + directory?: string + refreshed?: boolean + before: unknown + after: unknown + diff: unknown + } +} + export type EventLspClientDiagnostics = { type: "lsp.client.diagnostics" properties: { @@ -58,6 +70,8 @@ export type UserMessage = { tools?: { [key: string]: boolean } + sentEstimate?: number + contextEstimate?: number } export type ProviderAuthError = { @@ -130,6 +144,10 @@ export type AssistantMessage = { write: number } } + outputEstimate?: number + reasoningEstimate?: number + contextEstimate?: number + sentEstimate?: number finish?: string } @@ -651,6 +669,7 @@ export type EventFileWatcherUpdated = { export type Event = | EventInstallationUpdated | EventInstallationUpdateAvailable + | EventConfigUpdated | EventLspClientDiagnostics | EventLspUpdated | EventMessageUpdated @@ -803,6 +822,14 @@ export type KeybindsConfig = { * Previous recently used model */ model_cycle_recent_reverse?: string + /** + * Next favorite model + */ + model_cycle_favorite?: string + /** + * Previous favorite model + */ + model_cycle_favorite_reverse?: string /** * List available commands */ diff --git a/packages/sdk/python/src/opencode_ai/models/keybinds_config.py b/packages/sdk/python/src/opencode_ai/models/keybinds_config.py index f98b3b78e7d..034e9171cbc 100644 --- a/packages/sdk/python/src/opencode_ai/models/keybinds_config.py +++ b/packages/sdk/python/src/opencode_ai/models/keybinds_config.py @@ -43,6 +43,8 @@ class KeybindsConfig: model_list (Union[Unset, str]): List available models Default: 'm'. model_cycle_recent (Union[Unset, str]): Next recent model Default: 'f2'. model_cycle_recent_reverse (Union[Unset, str]): Previous recent model Default: 'shift+f2'. + model_cycle_favorite (Union[Unset, str]): Next favorite model Default: 'none'. + model_cycle_favorite_reverse (Union[Unset, str]): Previous favorite model Default: 'none'. agent_list (Union[Unset, str]): List agents Default: 'a'. agent_cycle (Union[Unset, str]): Next agent Default: 'tab'. agent_cycle_reverse (Union[Unset, str]): Previous agent Default: 'shift+tab'. @@ -95,6 +97,8 @@ class KeybindsConfig: model_list: Union[Unset, str] = "m" model_cycle_recent: Union[Unset, str] = "f2" model_cycle_recent_reverse: Union[Unset, str] = "shift+f2" + model_cycle_favorite: Union[Unset, str] = "none" + model_cycle_favorite_reverse: Union[Unset, str] = "none" agent_list: Union[Unset, str] = "a" agent_cycle: Union[Unset, str] = "tab" agent_cycle_reverse: Union[Unset, str] = "shift+tab" @@ -176,6 +180,10 @@ def to_dict(self) -> dict[str, Any]: model_cycle_recent_reverse = self.model_cycle_recent_reverse + model_cycle_favorite = self.model_cycle_favorite + + model_cycle_favorite_reverse = self.model_cycle_favorite_reverse + agent_list = self.agent_list agent_cycle = self.agent_cycle @@ -277,6 +285,10 @@ def to_dict(self) -> dict[str, Any]: field_dict["model_cycle_recent"] = model_cycle_recent if model_cycle_recent_reverse is not UNSET: field_dict["model_cycle_recent_reverse"] = model_cycle_recent_reverse + if model_cycle_favorite is not UNSET: + field_dict["model_cycle_favorite"] = model_cycle_favorite + if model_cycle_favorite_reverse is not UNSET: + field_dict["model_cycle_favorite_reverse"] = model_cycle_favorite_reverse if agent_list is not UNSET: field_dict["agent_list"] = agent_list if agent_cycle is not UNSET: @@ -381,6 +393,10 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: model_cycle_recent_reverse = d.pop("model_cycle_recent_reverse", UNSET) + model_cycle_favorite = d.pop("model_cycle_favorite", UNSET) + + model_cycle_favorite_reverse = d.pop("model_cycle_favorite_reverse", UNSET) + agent_list = d.pop("agent_list", UNSET) agent_cycle = d.pop("agent_cycle", UNSET) @@ -450,6 +466,8 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: model_list=model_list, model_cycle_recent=model_cycle_recent, model_cycle_recent_reverse=model_cycle_recent_reverse, + model_cycle_favorite=model_cycle_favorite, + model_cycle_favorite_reverse=model_cycle_favorite_reverse, agent_list=agent_list, agent_cycle=agent_cycle, agent_cycle_reverse=agent_cycle_reverse, diff --git a/packages/ui/src/components/select-dialog.tsx b/packages/ui/src/components/select-dialog.tsx index c54f4ec9d12..4806a12dd3a 100644 --- a/packages/ui/src/components/select-dialog.tsx +++ b/packages/ui/src/components/select-dialog.tsx @@ -14,6 +14,7 @@ interface SelectDialogProps emptyMessage?: string children: (item: T) => JSX.Element onSelect?: (value: T | undefined) => void + onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void } export function SelectDialog(props: SelectDialogProps) { @@ -65,9 +66,12 @@ export function SelectDialog(props: SelectDialogProps) { setStore("mouseActive", false) if (e.key === "Escape") return + const all = flat() + const selected = all.find((x) => others.key(x) === active()) + props.onKeyEvent?.(e, selected) + if (e.key === "Enter") { e.preventDefault() - const selected = flat().find((x) => others.key(x) === active()) if (selected) handleSelect(selected) } else { onKeyDown(e) diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index afcff3a0edd..989745c312c 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -38,6 +38,8 @@ OpenCode has a list of keybinds that you can customize through the OpenCode conf "model_list": "m", "model_cycle_recent": "f2", "model_cycle_recent_reverse": "shift+f2", + "model_cycle_favorite": "none", + "model_cycle_favorite_reverse": "none", "command_list": "ctrl+p", "agent_list": "a", "agent_cycle": "tab", diff --git a/script/sync/detect-conflicts.ts b/script/sync/detect-conflicts.ts new file mode 100644 index 00000000000..390383064bd --- /dev/null +++ b/script/sync/detect-conflicts.ts @@ -0,0 +1,151 @@ +#!/usr/bin/env bun +/** + * Conflict detection helper for upstream sync workflow + * + * This script: + * - Attempts a merge dry-run + * - Parses conflict output + * - Categorizes conflicts by file type + * - Returns resolution recommendations + */ + +import { $ } from "bun" + +interface ConflictInfo { + file: string + category: "lockfile" | "docs" | "config" | "custom" | "shared" + resolution: "auto-upstream" | "auto-regenerate" | "manual" + recommendation: string +} + +interface DetectionResult { + hasConflicts: boolean + conflicts: ConflictInfo[] + canAutoResolve: boolean + manualReviewRequired: string[] +} + +function categorizeFile(file: string): ConflictInfo["category"] { + if (file === "bun.lock" || file.endsWith(".lock")) return "lockfile" + if (file.endsWith(".md")) return "docs" + if (file === "package.json" || file.endsWith(".json")) return "config" + if (file.startsWith(".github/") || file.startsWith("script/sync/")) return "custom" + return "shared" +} + +function getResolution(category: ConflictInfo["category"]): Pick { + switch (category) { + case "lockfile": + return { + resolution: "auto-regenerate", + recommendation: "Regenerate by running `bun install` after resolving package.json", + } + case "docs": + return { + resolution: "auto-upstream", + recommendation: "Accept upstream version", + } + case "config": + return { + resolution: "manual", + recommendation: "Review changes carefully - may contain breaking dependency updates", + } + case "custom": + return { + resolution: "manual", + recommendation: "Keep local version - these are fork-specific customizations", + } + case "shared": + return { + resolution: "manual", + recommendation: "Review changes - may require merging upstream improvements with local modifications", + } + } +} + +async function detectConflicts(targetBranch = "dev"): Promise { + const result: DetectionResult = { + hasConflicts: false, + conflicts: [], + canAutoResolve: true, + manualReviewRequired: [], + } + + // Attempt merge with no-commit + const merge = await $`git merge ${targetBranch} --no-commit --no-ff`.nothrow().quiet() + + if (merge.exitCode === 0) { + // No conflicts, abort the merge to leave repo clean + await $`git merge --abort`.nothrow().quiet() + return result + } + + result.hasConflicts = true + + // Get list of conflicting files + const conflicts = await $`git diff --name-only --diff-filter=U`.text() + const files = conflicts.trim().split("\n").filter(Boolean) + + for (const file of files) { + const category = categorizeFile(file) + const { resolution, recommendation } = getResolution(category) + + const info: ConflictInfo = { + file, + category, + resolution, + recommendation, + } + + result.conflicts.push(info) + + if (resolution === "manual") { + result.canAutoResolve = false + result.manualReviewRequired.push(file) + } + } + + // Abort the merge to leave repo clean + await $`git merge --abort`.nothrow().quiet() + + return result +} + +async function main() { + const targetBranch = process.argv[2] || "dev" + + console.log(`Detecting conflicts when merging ${targetBranch}...\n`) + + const result = await detectConflicts(targetBranch) + + if (!result.hasConflicts) { + console.log("No conflicts detected. Merge can proceed cleanly.") + process.exit(0) + } + + console.log(`Found ${result.conflicts.length} conflicting file(s):\n`) + + for (const conflict of result.conflicts) { + console.log(` ${conflict.file}`) + console.log(` Category: ${conflict.category}`) + console.log(` Resolution: ${conflict.resolution}`) + console.log(` Recommendation: ${conflict.recommendation}`) + console.log() + } + + if (result.canAutoResolve) { + console.log("All conflicts can be auto-resolved.") + process.exit(0) + } + + console.log("Manual review required for:") + for (const file of result.manualReviewRequired) { + console.log(` - ${file}`) + } + process.exit(1) +} + +main().catch((err) => { + console.error("Error detecting conflicts:", err) + process.exit(1) +}) diff --git a/specs/config-spec.md b/specs/config-spec.md new file mode 100644 index 00000000000..53ad215b30c --- /dev/null +++ b/specs/config-spec.md @@ -0,0 +1,100 @@ +# PATCH /config Spec + +Provides concrete steps clients can follow to update runtime configuration without restarting the server. This section focuses on `PATCH /config`, but also highlights the companion `GET /config` for verification. + +## Purpose + +- Enables project- or global-scoped config updates that persist to disk. +- Returns the merged runtime configuration so clients immediately know the active state. +- Triggers targeted invalidation and publishes `config.updated` events after the response so other components can react. + +## Endpoint + +- **URL:** `/config` +- **Method:** `PATCH` +- **Query parameters:** + - `scope=project|global` (optional, defaults to `project`) + +## Request body + +Must satisfy the `Config.Info` schema (see `packages/opencode/src/config/config.ts`). Examples of supported keys: + +```jsonc +{ + "username": "new-name", + "agent": { + "build": { + "model": "anthropic/claude-3", + }, + }, + "share": "manual", +} +``` + +- Partial updates are merged deep into the existing configuration; unspecified keys inherit their current values. +- JSONC comments are preserved when writing back to disk. +- The server normalizes defaults (e.g., ensures `agent`, `mode`, `plugin`, `keybinds` exist) before persisting. + +## Behavior + +1. **Target file selection** + - `project` scope: the first existing file from `./.opencode/opencode.jsonc`, `./.opencode/opencode.json`, `./opencode.jsonc`, `./opencode.json`. If none exist, a new `./.opencode/opencode.jsonc` is created. + - `global` scope: always `~/.config/opencode/opencode.jsonc`; directories are created as needed. +2. Acquire a file lock (30s timeout) and backup the current file before modifications. +3. Merge the request payload with the target file’s content, validate against the schema, normalize defaults, and persist while preserving comments. +4. On success, delete the backup; on failure, restore the backup and raise `ConfigUpdateError`. +5. When `OPENCODE_CONFIG_HOT_RELOAD=true`, invalidate the registered `config` state so the next `Config.get()` reflects the update and powers hot reloads without restarting the server. If the flag is unset/false, the cached config intentionally remains in memory and `GET /config` continues to return the pre-patch view until the process restarts. +6. When hot reload is enabled, publish `config.updated` events via `Bus.publish` and, for project scope, only for the current directory (global scope notifies every directory tracked by `Instance.forEach`). With the flag disabled, events are suppressed so legacy integrations see the old cache. + +## Response + +- **200 OK** – returns the merged runtime config after applying the patch. +- Clients can immediately call `GET /config` (no query params) to double-check, or rely on the response body for the canonical view. + - Example response (truncated): + ```jsonc + { + "username": "new-name", + "agent": { ... }, + "share": "manual", + ... + } + ``` + +## Error cases + +- `400` – validation failures (body doesn’t match `Config.Info`, invalid plugin/agent entries, missing fields required by custom LSPs). +- Any other failure returns `500` with an error object that includes `data` and `errors` fields when triggered by `NamedError`. + +## Client workflow (curl example) + +```bash +SERVER=http://10.0.2.100:3366 + +# 1. inspect current config +curl "$SERVER/config" | jq . + +# 2. update username and sharing mode +curl -X PATCH "$SERVER/config" \ + -H 'Content-Type: application/json' \ + -d '{"username":"config-hot-reload-test","share":"manual"}' \ + | jq . + +# 3. verify changes persist +curl "$SERVER/config" | jq . +``` + +- With `OPENCODE_CONFIG_HOT_RELOAD=true`, no server restart is required because `Config.update` invalidates cached state and `Config.get()` reloads the merged data; when the flag is unset, plan for a restart before `GET /config` reflects the disk change. +- After the PATCH returns, subscribers (e.g., UI, CLI tooling) can listen for `config.updated` to refresh views or rerun initialization logic whenever hot reload is enabled. + +## Notes for integrators + +- If your integration maintains its own config cache, refresh it when you observe the `config.updated` event. +- Use `scope=global` when the update must affect every project directory; global updates are applied once and broadcast to all tracked directories. +- When calling from scripts, prefer `jq` or equivalent to diff the before/after payload, since the server returns the merged view. + +### Feature flag: `OPENCODE_CONFIG_HOT_RELOAD` + +- Default state: unset/false, which matches the legacy behavior where PATCH persists to disk but in-memory caches (and `GET /config`) are not refreshed until a restart. +- Hot reload path: set `OPENCODE_CONFIG_HOT_RELOAD=true` **before starting** the server or CLI to enable on-the-fly invalidations and `config.updated` bus events. +- Backward-compatibility check: with the flag unset, run the curl workflow above (`GET` → `PATCH` → `GET`) and confirm the final `GET` still returns the pre-patch configuration while the on-disk file has the new content. +- Regression test expectation: with the flag enabled, run `bun --cwd packages/opencode test config/hot-reload.test.ts` (or your integration-specific suite) to verify targeted invalidations and event fan-out continue to work.