Compare commits

..
Author SHA1 Message Date
Kit Langton 9a74b79834 fix(tui): persist plugin activation toggles 2026-08-13 17:03:50 -04:00
6435 changed files with 529166 additions and 584565 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Preserve prompt cache prefixes when sessions move between locations with unchanged instructions.
-1
View File
@@ -2,4 +2,3 @@ packages/core/migration/**/snapshot.json linguist-generated
packages/core/src/database/migration.gen.ts linguist-generated
packages/core/src/models-dev/snapshot.txt linguist-generated
packages/core/src/**/*.txt text eol=lf
packages/httpapi-codegen/test/generated/*.ts text eol=lf
+10 -17
View File
@@ -1,10 +1,6 @@
name: "Setup Bun"
description: "Setup Bun with caching and install dependencies"
inputs:
bun-version:
description: "Bun version to install instead of the root packageManager version"
required: false
default: ""
install-flags:
description: "Additional flags to pass to 'bun install'"
required: false
@@ -24,37 +20,34 @@ runs:
shell: bash
run: |
if [ "$RUNNER_ARCH" = "X64" ]; then
V="${{ inputs.bun-version }}"
if [ -z "$V" ]; then V=$(node -p "require('./package.json').packageManager.split('@')[1]"); fi
TAG=$([ "$V" = "canary" ] && echo "canary" || echo "bun-v${V}")
V=$(node -p "require('./package.json').packageManager.split('@')[1]")
case "$RUNNER_OS" in
macOS) OS=darwin ;;
Linux) OS=linux ;;
Windows) OS=windows ;;
esac
echo "url=https://github.com/oven-sh/bun/releases/download/${TAG}/bun-${OS}-x64-baseline.zip" >> "$GITHUB_OUTPUT"
echo "url=https://github.com/oven-sh/bun/releases/download/bun-v${V}/bun-${OS}-x64-baseline.zip" >> "$GITHUB_OUTPUT"
fi
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: ${{ !steps.bun-url.outputs.url && inputs.bun-version || '' }}
bun-version-file: ${{ !steps.bun-url.outputs.url && !inputs.bun-version && 'package.json' || '' }}
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
bun-download-url: ${{ steps.bun-url.outputs.url }}
- name: Get cache directory
id: cache
shell: bash
run: |
echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
echo "version=$(bun --version)" >> "$GITHUB_OUTPUT"
run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
- name: Restore Bun dependencies
id: bun-cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ steps.cache.outputs.dir }}
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ steps.cache.outputs.version }}-${{ hashFiles('bun.lock', 'patches/**') }}
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install setuptools for distutils compatibility
run: python3 -m pip install setuptools || pip install setuptools || true
@@ -66,9 +59,9 @@ runs:
# e.g. ./patches/ for standard-openapi
# https://github.com/oven-sh/bun/issues/28147
if [ "$RUNNER_OS" = "Windows" ]; then
bun install --frozen-lockfile --linker hoisted ${{ inputs.install-flags }}
bun install --linker hoisted ${{ inputs.install-flags }}
else
bun install --frozen-lockfile ${{ inputs.install-flags }}
bun install ${{ inputs.install-flags }}
fi
shell: bash
@@ -77,4 +70,4 @@ runs:
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ steps.cache.outputs.dir }}
key: ${{ steps.bun-cache.outputs.cache-primary-key }}
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
+37
View File
@@ -0,0 +1,37 @@
name: beta
on:
workflow_dispatch:
schedule:
- cron: "0 * * * *"
jobs:
sync:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Setup Git Committer
id: setup-git-committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Install OpenCode
run: bun i -g opencode-ai
- name: Sync beta branch
env:
GH_TOKEN: ${{ steps.setup-git-committer.outputs.token }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
run: bun script/beta.ts
-26
View File
@@ -1,26 +0,0 @@
name: check
on:
push:
branches: [dev, v2]
pull_request:
branches: [dev, v2]
workflow_dispatch:
concurrency:
group: ${{ case(github.ref == 'refs/heads/dev', format('{0}-{1}', github.workflow, github.run_id), format('{0}-{1}', github.workflow, github.event.pull_request.number || github.ref)) }}
cancel-in-progress: true
jobs:
check:
name: typecheck
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Run checks
run: bun run check
+7 -7
View File
@@ -15,7 +15,7 @@ jobs:
close-non-compliant:
runs-on: ubuntu-latest
steps:
- name: Close non-compliant issues and PRs after 72 hours
- name: Close non-compliant issues and PRs after 2 hours
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
@@ -33,7 +33,7 @@ jobs:
}
const now = Date.now();
const seventyTwoHours = 72 * 60 * 60 * 1000;
const twoHours = 2 * 60 * 60 * 1000;
const orgMemberAssociations = new Set(['OWNER', 'MEMBER']);
const agentLogin = 'opencode-agent[bot]';
const { data: file } = await github.rest.repos.getContent({
@@ -87,14 +87,14 @@ jobs:
if (!complianceComment) continue;
const commentAge = now - new Date(complianceComment.created_at).getTime();
if (commentAge < seventyTwoHours) {
core.info(`${kind} #${item.number} still within 72-hour window (${Math.round(commentAge / 60000)}m elapsed)`);
if (commentAge < twoHours) {
core.info(`${kind} #${item.number} still within 2-hour window (${Math.round(commentAge / 60000)}m elapsed)`);
continue;
}
const closeMessage = isPR
? 'This pull request has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) within the 72-hour window.\n\nFeel free to open a new pull request that follows our guidelines.'
: 'This issue has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) within the 72-hour window.\n\nFeel free to open a new issue that follows our issue templates.';
? 'This pull request has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) within the 2-hour window.\n\nFeel free to open a new pull request that follows our guidelines.'
: 'This issue has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) within the 2-hour window.\n\nFeel free to open a new issue that follows our issue templates.';
await github.rest.issues.createComment({
owner: context.repo.owner,
@@ -129,5 +129,5 @@ jobs:
});
}
core.info(`Closed non-compliant ${kind} #${item.number} after 72-hour window`);
core.info(`Closed non-compliant ${kind} #${item.number} after 2-hour window`);
}
-34
View File
@@ -1,34 +0,0 @@
name: deploy-files
on:
push:
branches:
- dev
- v2
workflow_dispatch:
concurrency:
group: deploy-files-${{ github.ref_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'v2')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
- name: Typecheck
working-directory: services/files
run: bun typecheck
- name: Deploy
working-directory: services/files
run: bun run deploy --env ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+49
View File
@@ -0,0 +1,49 @@
name: deploy-lab-catalog
on:
push:
branches: [v2]
paths:
- ".github/workflows/deploy-lab-catalog.yml"
- "bun.lock"
- "package.json"
- "packages/drive/**"
- "packages/protocol/src/simulation.ts"
- "packages/simulation/**"
- "packages/lab/catalog/**"
workflow_dispatch:
concurrency:
group: deploy-lab-catalog-${{ github.ref_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: ./.github/actions/setup-bun
- name: Install ffmpeg
run: |
sudo apt-get update
sudo apt-get install --yes ffmpeg
- name: Validate
run: |
bun --cwd packages/protocol typecheck
bun --cwd packages/simulation typecheck
bun --cwd packages/drive run check
bun --cwd packages/drive run test
bun --cwd packages/lab/catalog run check
- name: Deploy
working-directory: packages/lab/catalog
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
-37
View File
@@ -1,37 +0,0 @@
name: deploy-posts
on:
push:
branches:
- v2
paths:
- packages/posts/**
- bun.lock
- .github/workflows/deploy-posts.yml
workflow_dispatch:
concurrency:
group: deploy-posts-${{ github.ref_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
- name: Build
working-directory: packages/posts
run: bun run build
- name: Deploy
working-directory: packages/posts
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+3 -2
View File
@@ -24,13 +24,14 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Build
working-directory: services/www
working-directory: packages/www
run: bun run build
env:
BLUME_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
CLOUDFLARE_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
- name: Deploy
working-directory: services/www
working-directory: packages/www
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+1 -3
View File
@@ -5,7 +5,6 @@ on:
branches:
- dev
- production
- beta
workflow_dispatch:
concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -16,7 +15,7 @@ permissions:
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production' || github.ref_name == 'beta')
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production')
runs-on: ubuntu-latest
environment: ${{ github.ref_name }}
steps:
@@ -29,7 +28,6 @@ jobs:
node-version: "24"
- uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1
if: github.ref_name != 'beta'
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
role-session-name: opencode-${{ github.run_id }}
+2 -2
View File
@@ -107,7 +107,7 @@ jobs:
If the issue is NOT compliant and the author association is not OWNER or MEMBER, start the comment with:
<!-- issue-compliance -->
Then explain what needs to be fixed and that they have 72 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance
Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance
If duplicates were found, include a section about potential duplicates with links.
@@ -124,7 +124,7 @@ jobs:
**What needs to be fixed:**
- [specific reasons]
Please edit this issue to address the above within **72 hours**, or it will be automatically closed.
Please edit this issue to address the above within **2 hours**, or it will be automatically closed.
[If duplicates found, add:]
---
+52
View File
@@ -0,0 +1,52 @@
name: generate
on:
push:
branches:
- dev
- v2
jobs:
generate:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Generate
run: ./script/generate.ts
- name: Commit and push
run: |
if [ -z "$(git status --porcelain)" ]; then
echo "No changes to commit"
exit 0
fi
git add -A
git commit -m "chore: generate" --allow-empty
git push origin HEAD:${{ github.ref_name }} --no-verify
# if ! git push origin HEAD:${{ github.event.pull_request.head.ref || github.ref_name }} --no-verify; then
# echo ""
# echo "============================================"
# echo "Failed to push generated code."
# echo "Please run locally and push:"
# echo ""
# echo " ./script/generate.ts"
# echo " git add -A && git commit -m \"chore: generate\" && git push"
# echo ""
# echo "============================================"
# exit 1
# fi
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
echo "=== Flake structure ==="
nix flake show --all-systems
SYSTEMS="x86_64-linux aarch64-linux aarch64-darwin"
SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin"
PACKAGES="opencode"
# TODO: move 'desktop' to PACKAGES when #11755 is fixed
OPTIONAL_PACKAGES="desktop"
+4 -3
View File
@@ -6,12 +6,11 @@ permissions:
on:
workflow_dispatch:
push:
branches: [dev, beta, v2]
branches: [dev, beta]
paths:
- "bun.lock"
- "package.json"
- "packages/*/package.json"
- "services/*/package.json"
- "flake.lock"
- "nix/node_modules.nix"
- "nix/scripts/**"
@@ -34,6 +33,8 @@ jobs:
runner: blacksmith-4vcpu-ubuntu-2404
- system: aarch64-linux
runner: blacksmith-4vcpu-ubuntu-2404-arm
- system: x86_64-darwin
runner: macos-15-intel
- system: aarch64-darwin
runner: macos-latest
runs-on: ${{ matrix.runner }}
@@ -124,7 +125,7 @@ jobs:
[ -f "$HASH_FILE" ] || echo '{"nodeModules":{}}' > "$HASH_FILE"
for SYSTEM in x86_64-linux aarch64-linux aarch64-darwin; do
for SYSTEM in x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin; do
FILE="hashes/hash-${SYSTEM}/hash.txt"
if [ -f "$FILE" ]; then
HASH="$(tr -d '[:space:]' < "$FILE")"
+2 -11
View File
@@ -135,16 +135,7 @@ jobs:
const linkedIssues = result.repository.pullRequest.closingIssuesReferences.totalCount;
// GitHub only populates closingIssuesReferences when a PR targets the repository's
// default branch (dev). PRs targeting other branches like v2 always return totalCount 0.
// Fall back to checking the PR description for closing keywords (e.g. Closes #123).
const body = pr.body || '';
const issueMatch = body.match(/### Issue for this PR\s*\n([\s\S]*?)(?=###|$)/);
const issueContent = issueMatch ? issueMatch[1].trim() : body;
const hasBodyIssueRef = /(closes|fixes|resolves)\s+#\d+/i.test(issueContent) || /#\d+/.test(issueContent);
const hasLinkedIssue = linkedIssues > 0 || hasBodyIssueRef;
if (!hasLinkedIssue) {
if (linkedIssues === 0) {
await addLabel('needs:issue');
await comment('issue', `Thanks for your contribution!
@@ -307,7 +298,7 @@ jobs:
**What needs to be fixed:**
${issues.map(i => `- ${i}`).join('\n')}
Please edit this PR description to address the above within **72 hours**, or it will be automatically closed.
Please edit this PR description to address the above within **2 hours**, or it will be automatically closed.
If you believe this was flagged incorrectly, please let a maintainer know.`;
+69 -98
View File
@@ -7,7 +7,6 @@ on:
- ci
- dev
- beta
- v2
- fix/npm-native-binary-install
- snapshot-*
workflow_dispatch:
@@ -24,12 +23,8 @@ on:
description: "Override version (optional)"
required: false
type: string
release_notes:
description: "Reviewed V2 release notes for the Discord announcement (optional)"
required: false
type: string
concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ (github.ref_name == 'v2' && (inputs.version || inputs.bump) && 'release') || inputs.version || inputs.bump }}
concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.version || inputs.bump }}
permissions:
id-token: write
@@ -37,7 +32,7 @@ permissions:
packages: write
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && !inputs.bump && !inputs.version && 'dev') || '' }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'next') || '' }}
jobs:
version:
@@ -50,13 +45,6 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Deploy update service
if: github.ref_name == 'v2'
working-directory: services/update
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
@@ -86,16 +74,13 @@ jobs:
build-cli:
needs: version
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 30
if: github.repository == 'anomalyco/opencode'
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
with:
fetch-tags: true
- uses: ./.github/actions/setup-bun
with:
bun-version: 1.4.2
- name: Setup git committer
id: committer
@@ -117,7 +102,6 @@ jobs:
id: build
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env:
BUN_COMPILE_RELEASE: bun-v1.4.2
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
GH_REPO: ${{ needs.version.outputs.repo }}
@@ -172,7 +156,7 @@ jobs:
fi
found=0
for file in packages/cli/dist/cli-darwin-*/bin/opencode; do
for file in packages/cli/dist/cli-darwin-*/bin/opencode2; do
if [ ! -f "$file" ]; then
continue
fi
@@ -195,37 +179,13 @@ jobs:
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-preview-cli-macos
name: opencode-preview-cli
path: packages/cli/dist/cli-*
if-no-files-found: error
build-node-app-archive:
needs: version
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 30
if: github.repository == 'anomalyco/opencode' && !(github.ref_name == 'v2' && (inputs.bump || inputs.version))
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
- name: Build app archive
run: bun packages/cli/script/build-node.ts --app-archive-only --app-archive=.cache/app-archive.bin --skip-install
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-node-app-archive
path: packages/cli/.cache/app-archive.bin
if-no-files-found: error
build-node-cli:
needs:
- version
- build-node-app-archive
if: github.repository == 'anomalyco/opencode' && !(github.ref_name == 'v2' && (inputs.bump || inputs.version))
needs: version
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
strategy:
fail-fast: false
matrix:
@@ -238,7 +198,6 @@ jobs:
host: macos-26
- target: windows-arm64
host: blacksmith-4vcpu-windows-2025
bun_install_flags: --cpu=*
- target: windows-x64
host: blacksmith-4vcpu-windows-2025
runs-on: ${{ matrix.settings.host }}
@@ -250,19 +209,14 @@ jobs:
- uses: ./.github/actions/setup-bun
with:
install-flags: ${{ matrix.settings.bun_install_flags }}
install-flags: --os=* --cpu=*
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "26.4.0"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: opencode-node-app-archive
path: packages/cli/.cache
- name: Build
run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node --app-archive=.cache/app-archive.bin
run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
@@ -280,9 +234,10 @@ jobs:
sign-cli-windows:
needs:
- sign-cli-macos
- build-cli
- version
runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'v2' || github.ref_name == 'beta')
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2' && github.ref_name != 'beta'
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
@@ -295,8 +250,15 @@ jobs:
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: opencode-preview-cli-macos
path: packages/cli/dist
name: opencode-cli-windows
path: packages/opencode/dist
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Azure login
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
@@ -311,9 +273,9 @@ jobs:
signing-account-name: ${{ env.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
certificate-profile-name: ${{ env.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
files: |
${{ github.workspace }}\packages\cli\dist\cli-windows-arm64\bin\opencode.exe
${{ github.workspace }}\packages\cli\dist\cli-windows-x64\bin\opencode.exe
${{ github.workspace }}\packages\cli\dist\cli-windows-x64-baseline\bin\opencode.exe
${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe
exclude-environment-credential: true
exclude-workload-identity-credential: true
exclude-managed-identity-credential: true
@@ -329,9 +291,9 @@ jobs:
shell: pwsh
run: |
$files = @(
"${{ github.workspace }}\packages\cli\dist\cli-windows-arm64\bin\opencode.exe",
"${{ github.workspace }}\packages\cli\dist\cli-windows-x64\bin\opencode.exe",
"${{ github.workspace }}\packages\cli\dist\cli-windows-x64-baseline\bin\opencode.exe"
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe",
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe",
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe"
)
foreach ($file in $files) {
@@ -341,17 +303,39 @@ jobs:
}
}
- name: Repack Windows CLI archives
working-directory: packages/opencode/dist
shell: pwsh
run: |
Compress-Archive -Path "opencode-windows-arm64\bin\*" -DestinationPath "opencode-windows-arm64.zip" -Force
Compress-Archive -Path "opencode-windows-x64\bin\*" -DestinationPath "opencode-windows-x64.zip" -Force
Compress-Archive -Path "opencode-windows-x64-baseline\bin\*" -DestinationPath "opencode-windows-x64-baseline.zip" -Force
- name: Upload signed Windows CLI release assets
if: needs.version.outputs.release != ''
shell: pwsh
env:
GH_TOKEN: ${{ steps.committer.outputs.token }}
run: |
gh release upload "v${{ needs.version.outputs.version }}" `
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64.zip" `
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64.zip" `
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline.zip" `
--clobber `
--repo "${{ needs.version.outputs.repo }}"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-preview-cli
path: packages/cli/dist/cli-*
if-no-files-found: error
name: opencode-cli-signed-windows
path: |
packages/opencode/dist/opencode-windows-arm64
packages/opencode/dist/opencode-windows-x64
packages/opencode/dist/opencode-windows-x64-baseline
build-electron:
needs:
- version
- sign-cli-windows
if: github.repository == 'anomalyco/opencode' && (github.ref_name != 'v2' || needs.version.outputs.release != '')
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -389,11 +373,6 @@ jobs:
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: opencode-preview-cli
path: packages/cli/dist
- uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2.0.0
if: runner.os == 'macOS'
with:
@@ -452,7 +431,6 @@ jobs:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
OPENCODE_CLI_DIST: ${{ github.workspace }}/packages/cli/dist
- name: Build
run: bun run build
@@ -469,7 +447,6 @@ jobs:
VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }}
VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
OPENCODE_CLI_DIST: ${{ github.workspace }}/packages/cli/dist
- name: Package
if: needs.version.outputs.release
@@ -545,7 +522,6 @@ jobs:
- version
- build-cli
- sign-cli-macos
- build-node-app-archive
- build-node-cli
- sign-cli-windows
- build-electron
@@ -593,12 +569,13 @@ jobs:
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'beta'
with:
name: opencode-preview-cli
path: packages/cli/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: needs.build-node-cli.result == 'success'
if: github.ref_name != 'beta'
with:
pattern: opencode-node-cli-*
path: packages/cli/dist/node
@@ -643,6 +620,19 @@ jobs:
git config --global user.name "opencode"
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts || true
- name: Upload desktop release assets
if: needs.version.outputs.release
env:
GH_TOKEN: ${{ steps.committer.outputs.token }}
run: |
shopt -s nullglob
files=(/tmp/desktop/*.{exe,blockmap,dmg,zip,AppImage,deb,rpm} /tmp/desktop/*.app.tar.gz)
if (( ${#files[@]} == 0 )); then
echo "No desktop release assets found"
exit 1
fi
gh release upload "v${{ needs.version.outputs.version }}" "${files[@]}" --clobber --repo "${{ needs.version.outputs.repo }}"
- run: ./script/publish.ts
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
@@ -654,22 +644,3 @@ jobs:
LATEST_YML_DIR: /tmp/latest-yml
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
OPENCODE_DESKTOP_DIST: /tmp/desktop
CLOUDFLARE_ACCOUNT_ID: 15d29c8639fd3733b1b5486a2acfd968
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
notify-discord-v2:
needs:
- version
- publish
if: ${{ !cancelled() && github.repository == 'anomalyco/opencode' && github.ref_name == 'v2' && needs.version.outputs.release && needs.publish.result == 'success' }}
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
# Unlike dev, V2 publishes a tag rather than a GitHub Release event.
- name: Announce V2 release in Discord
uses: SethCohen/github-releases-to-discord@24d166886aee4646d448c8a389ff9e1ebcab3682 # v1.20.0
with:
webhook_url: ${{ secrets.DISCORD_WEBHOOK }}
release_name: OpenCode V2 ${{ needs.version.outputs.tag }}
release_body: ${{ inputs.release_notes }}
release_html_url: https://github.com/${{ github.repository }}/tree/${{ needs.version.outputs.tag }}
+14 -99
View File
@@ -22,36 +22,6 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
affected:
name: affected packages
runs-on: blacksmith-4vcpu-ubuntu-2404
outputs:
app: ${{ steps.packages.outputs.app }}
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: package.json
- name: Find affected packages
id: packages
env:
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "app=true" >> "$GITHUB_OUTPUT"
exit 0
fi
bun x turbo@2.10.2 ls --affected --filter=@opencode/app --output=json > affected.json
bun -e 'const result = await Bun.file("affected.json").json(); console.log(`app=${result.packages.count > 0}`)' >> "$GITHUB_OUTPUT"
unit:
name: unit (${{ matrix.settings.name }})
strategy:
@@ -71,7 +41,6 @@ jobs:
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
@@ -81,19 +50,17 @@ jobs:
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Test Effect simplification rules
if: runner.os == 'Linux'
run: bun run test:effect-simplification-rules
- name: Check Effect simplifications
if: runner.os == 'Linux'
run: bun run lint:effect-simplifications
- name: Configure git identity
run: |
git config --global user.email "bot@opencode.ai"
git config --global user.name "opencode"
- name: Install ffmpeg
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install --yes ffmpeg
- name: Cache Turbo
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
@@ -105,42 +72,14 @@ jobs:
- name: Run unit tests
timeout-minutes: 20
run: |
# The runners have four vCPUs, and each Bun test process performs its own concurrent work.
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
GITHUB_ACTIONS=false bun turbo test --concurrency=3
exit 0
fi
GITHUB_ACTIONS=false bun turbo test --affected --concurrency=3
run: GITHUB_ACTIONS=false bun turbo test ${{ runner.os == 'Windows' && '--filter=!opencode-drive' || '' }}
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
- name: Verify published codemode package
if: runner.os == 'Linux'
working-directory: packages/codemode
run: bun run script/publish.ts --dry-run
- name: Verify packed workerd SDK
if: runner.os == 'Linux'
timeout-minutes: 15
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
bun turbo verify:package --filter=@opencode/sdk
exit 0
fi
bun turbo verify:package --affected --filter=@opencode/sdk
env:
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
- name: Verify compiled service lifecycle
if: always()
timeout-minutes: 10
working-directory: packages/cli
env:
NODE_OPTIONS: ${{ runner.os == 'Windows' && '--max-old-space-size=4096' || '' }}
run: |
bun run script/build.ts --single --skip-install
bun run script/service-smoke.ts
@@ -155,8 +94,6 @@ jobs:
if: always()
timeout-minutes: 15
working-directory: packages/cli
env:
NODE_OPTIONS: ${{ runner.os == 'Windows' && '--max-old-space-size=4096' || '' }}
run: |
bun run script/build-node.ts --single --skip-install --outdir=dist/node
bun run script/service-smoke.ts --node
@@ -168,12 +105,12 @@ jobs:
- name: Check generated documentation
if: runner.os == 'Linux'
working-directory: services/www
working-directory: packages/www
run: bun run check:generated
e2e:
name: e2e (${{ matrix.settings.name }})
needs: affected
if: github.ref_name != 'v2' && github.head_ref != 'v2'
strategy:
fail-fast: false
matrix:
@@ -184,38 +121,32 @@ jobs:
host: blacksmith-4vcpu-windows-2025
runs-on: ${{ matrix.settings.host }}
env:
E2E_ENABLED: ${{ needs.affected.outputs.app == 'true' && github.ref_name != 'v2' && github.head_ref != 'v2' }}
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
defaults:
run:
shell: bash
steps:
- name: Checkout repository
if: env.E2E_ENABLED == 'true'
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
if: env.E2E_ENABLED == 'true'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
# Playwright 1.59 hangs while extracting Chromium with Node 24.16.
node-version: "24.15"
- name: Setup Bun
if: env.E2E_ENABLED == 'true'
uses: ./.github/actions/setup-bun
- name: Read Playwright version
if: env.E2E_ENABLED == 'true'
id: playwright-version
run: |
version=$(node -e 'console.log(require("./package.json").workspaces.catalog["@playwright/test"])')
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
if: env.E2E_ENABLED == 'true'
id: playwright-cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
@@ -223,37 +154,23 @@ jobs:
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
- name: Install Playwright system dependencies
if: env.E2E_ENABLED == 'true' && runner.os == 'Linux'
if: runner.os == 'Linux'
working-directory: packages/app
run: bunx playwright install-deps chromium
- name: Install Playwright browsers
if: env.E2E_ENABLED == 'true' && steps.playwright-cache.outputs.cache-hit != 'true'
if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: packages/app
run: bunx playwright install chromium
- name: Run app e2e tests against production build
if: env.E2E_ENABLED == 'true'
run: bun --cwd packages/app test:e2e:built
- name: Run app e2e tests
run: bun --cwd packages/app test:e2e:local
env:
CI: true
timeout-minutes: 30
- name: Verify service worker precaching and upgrades
if: env.E2E_ENABLED == 'true'
working-directory: packages/app
run: bunx playwright test --config e2e/service-worker/playwright.config.ts
timeout-minutes: 5
- name: Run session UI component tests
if: ${{ !cancelled() && env.E2E_ENABLED == 'true' }}
run: bun --cwd packages/session-ui test:components
env:
CI: true
timeout-minutes: 15
- name: Upload Playwright artifacts
if: always() && env.E2E_ENABLED == 'true'
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
@@ -262,5 +179,3 @@ jobs:
path: |
packages/app/e2e/test-results
packages/app/e2e/playwright-report
packages/session-ui/component-tests/test-results
packages/session-ui/component-tests/playwright-report
+21
View File
@@ -0,0 +1,21 @@
name: typecheck
on:
push:
branches: [dev, v2]
pull_request:
branches: [dev, v2]
workflow_dispatch:
jobs:
typecheck:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Run typecheck
run: bun typecheck
-1
View File
@@ -32,7 +32,6 @@ target
# Local dev files
opencode-dev
UPCOMING_CHANGELOG.md
RELEASE_REVIEW.md
logs/
*.bun-build
tsconfig.tsbuildinfo
+1 -1
View File
@@ -17,4 +17,4 @@ if (process.versions.bun !== expectedBunVersion) {
console.warn(`Warning: Bun version ${process.versions.bun} differs from expected ${expectedBunVersion}`);
}
'
bun run check
bun typecheck
+1 -1
View File
@@ -2,7 +2,7 @@
description: "Bump AI sdk dependencies minor / patch versions only"
---
Please read @package.json and @packages/core/package.json.
Please read @package.json and @packages/opencode/package.json.
Your job is to look into AI SDK dependencies, figure out if they have versions that can be upgraded (minor or patch versions ONLY no major ignore major changes).
+9 -1
View File
@@ -6,7 +6,15 @@ subtask: true
commit and push
Use `type(scope): summary` with one of these types: `feat`, `fix`, `docs`, `chore`, `refactor`, or `test`. The scope is optional.
make sure it includes a prefix like
docs:
tui:
core:
ci:
ignore:
wip:
For anything in the packages/web use the docs: prefix.
prefer to explain WHY something was done from an end user perspective instead of
WHAT was done.
+1 -1
View File
@@ -2,7 +2,7 @@
description: Remove AI code slop
---
Check the diff against `origin/v2`, and remove all AI generated slop introduced in this branch.
Check the diff against dev, and remove all AI generated slop introduced in this branch.
This includes:
-48
View File
@@ -1,48 +0,0 @@
# he Glossary
## Sources
- Hebrew Academy approved IT terminology: https://terms.hebrew-academy.org.il/Millonim/ShowMillon?KodMillon=192
- Firefox Hebrew localization corpus: https://github.com/mozilla-l10n/firefox-l10n/tree/main/he
- KDE Hebrew localization team and corpus: https://l10n.kde.org/team-infos.php?teamcode=he
- Community-maintained VS Code Hebrew language pack: https://github.com/AMAARETS/vscode-language-pack-he
- Microsoft Hebrew developer documentation for Git terminology: https://learn.microsoft.com/he-il/power-platform/alm/tutorials/github-actions-deploy
- W3C guidance for bidirectional text: https://www.w3.org/International/articles/strings-and-bidi/
## Do Not Translate (Locale Additions)
- `OpenCode` (preserve casing in prose and UI copy)
- `API`, `MCP`, `LSP`, `OAuth`, `Git`, model names, and provider names
- Commands, flags, keyboard shortcuts, file paths, URLs, identifiers, hashes, and code literals
- Keep `commit` and `diff` when they name the exact Git artifact or operation
## Preferred Terms
| English / Context | Preferred | Notes |
| ----------------- | ------------- | ------------------------------------------------------------------------- |
| session | `הפעלה` | Use `שיחה` only when the source specifically means a chat or conversation |
| workspace | `סביבת עבודה` | |
| terminal | `מסוף` | Prefer the established Hebrew term over transliteration |
| command | `פקודה` | |
| provider | `ספק` | Use `ספק מודלים` where the bare noun is ambiguous |
| model | `מודל` | |
| API key | `מפתח API` | Keep the acronym in Latin letters |
| plugin | `תוסף` | |
| repository | `מאגר` | Use `מאגר Git` where context is ambiguous |
| branch | `ענף` | |
| context | `הקשר` | Use `חלון הקשר` for context window |
| tokens | `אסימונים` | |
## Guidance
- Prefer natural modern Israeli Hebrew over word-for-word translation or obscure coined terms.
- Use short action verbs for controls and translate complete phrases in context.
- Keep recognized developer acronyms and exact Git vocabulary in Latin script instead of phonetic transliteration.
- Treat embedded code, paths, commands, shortcuts, hashes, model IDs, and other Latin technical artifacts as LTR content inside the RTL interface.
- Keep recurring concepts consistent and do not collapse session, chat, run, and launch into one Hebrew term.
## Avoid
- Avoid transliterations such as `טרמינל`, `פלאגין`, and `קומנד` when `מסוף`, `תוסף`, and `פקודה` are clear.
- Avoid translating `commit` as `התחייבות`.
- Avoid inventing Hebrew expansions for `API`, `MCP`, or `LSP`.
File diff suppressed because it is too large. Load diff
+7 -7
View File
@@ -1,6 +1,6 @@
---
name: effect
description: Work with Effect v4 TypeScript code in this repo
description: Work with Effect v4 / effect-smol TypeScript code in this repo
---
# Effect
@@ -9,10 +9,10 @@ This codebase uses Effect for typed, composable TypeScript services, schemas, an
## Source Of Truth
Use the current Effect v4 source, not memory or older Effect v2/v3 examples.
Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3 examples.
1. If `.opencode/references/effect` is missing, clone `https://github.com/Effect-TS/effect` there. Do this in the project, not in the skill folder.
2. Search `.opencode/references/effect` for exact APIs, examples, tests, and naming patterns before answering or implementing Effect-specific code.
1. If `.opencode/references/effect-smol` is missing, clone `https://github.com/Effect-TS/effect-smol` there. Do this in the project, not in the skill folder.
2. Search `.opencode/references/effect-smol` for exact APIs, examples, tests, and naming patterns before answering or implementing Effect-specific code.
3. Also inspect existing repo code for local house style before introducing new patterns.
4. Prefer answers and implementations backed by specific source files or nearby repo examples.
@@ -27,12 +27,12 @@ Use the current Effect v4 source, not memory or older Effect v2/v3 examples.
- Keep layer composition explicit. Avoid broad hidden provisioning that makes missing dependencies hard to see.
- In tests, prefer the repo's existing Effect test helpers and live tests for filesystem, git, child process, locks, or timing behavior.
- Do not introduce `any`, non-null assertions, unchecked casts, or older Effect APIs just to satisfy types.
- Do not answer from memory. Verify against `.opencode/references/effect` or nearby code first.
- Do not answer from memory. Verify against `.opencode/references/effect-smol` or nearby code first.
## Testing Patterns
- Use `testEffect(...)` from `packages/core/test/lib/effect.ts` for tests that exercise Effect services, layers, runtime context, scoped resources, or platform integrations.
- Use `testEffect(...)` from `packages/opencode/test/lib/effect.ts` for tests that exercise Effect services, layers, runtime context, scoped resources, or platform integrations.
- Use `it.live(...)` for filesystem, git repositories, HTTP servers, sockets, child processes, locks, real time, and other live platform behavior.
- Run tests from package directories such as `packages/core`; never run package tests from the repo root.
- Run tests from package directories such as `packages/opencode`; never run package tests from the repo root.
- Prefer explicit test layers over ad hoc managed runtimes. Keep dependency provisioning visible in the test file.
- Use scoped fixtures and finalizers for resources that must be cleaned up, including temporary directories, flags, databases, fibers, servers, and global state.
-231
View File
@@ -1,231 +0,0 @@
---
name: opencode-dev
description: Use when interactively running, debugging, or verifying opencode's own V2 CLI/TUI or server during development in this repo — starting the dev TUI, driving it with termctrl, comparing V2 against the legacy TUI, hitting the V2 server/API directly, reading log files, or attaching Bun's inspector.
---
# Debugging opencode itself
Workflow for interactively exercising the V2 CLI/TUI and server while developing in this repo. All commands below run from `packages/cli` unless noted otherwise.
## Server/client model
opencode V2 is a client/server system, not a single monolithic process:
- **Server process** runs the Effect HTTP API (`packages/server`) and owns all domain state: sessions, database, plugins, permissions, Location services. It's started by the `serve` command (`packages/cli/src/commands/handlers/serve.ts`).
- **TUI process** is a separate process that runs no application logic itself — it's an HTTP/SSE client of the server via the generated SDK (`createOpencodeClient` / `sdk.client.v2`).
- **Discovery**: CLI processes find the shared server through a JSON registration file at `~/.local/state/opencode/service.json` (or `service-local.json` for the local/dev channel) containing `{id, version, url, pid}`. A separate password file under `~/.config/opencode/service.json` provides HTTP Basic auth. Before reusing a registration, the client calls `GET /health` to confirm the server is alive, authenticated, and version-compatible.
- **Sharing**: because of this registration/health-check dance, many concurrent `opencode`/TUI invocations converge on one shared background daemon rather than each spawning their own. If no compatible healthy daemon is found, a new one is spawned detached (`serve --service`) and registers itself.
- **`bun dev service start|status|stop|restart`** manages this shared background daemon's lifecycle directly — useful when you need to force a fresh server, confirm one is running, or kill a stuck one.
- **Standalone mode** (`--standalone`) opts a single invocation out of the shared daemon: it spawns a private one-off `serve --stdio --port 0` child tied to that invocation's lifetime, with its own random password. Use this to isolate a debugging session from your other running opencode sessions.
- Every log line is tagged `role=server` or `role=cli` and a per-process `run=<id>`, so you can distinguish server-side and client-side activity in one shared log file (see "Logs" below) even when both roles are interleaved from concurrent processes.
## Starting the dev TUI
- This package is the V2 CLI adapter. Run its `dev` script when testing the TUI; do not use the repository-root `bun dev`, which launches the legacy `packages/opencode` CLI.
- Run commands from `packages/cli`. Use `bun dev` for most debugging so the TUI starts with a private V2 server.
## Interactive debugging with termctrl
- Use `termctrl` for interactive checks instead of starting the TUI as a blocking foreground process. It provides a real PTY, handles OpenTUI's host handshake, and can save reviewable screenshots.
- Use a dedicated session name and do not reuse or kill an unrelated session.
```bash
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev
termctrl wait opencode-v2-dev "Ask anything" --timeout 20000
termctrl show opencode-v2-dev
```
- Wait for visible text before interacting instead of relying on fixed sleeps. Use the text expected from the screen under test, such as `Ask anything` or `Connect a provider`.
- Drive the running TUI with `termctrl send`. Prefix typed input with `text:` and send control keys separately so the interaction matches real terminal input.
```bash
termctrl send opencode-v2-dev 'text:example prompt' enter
termctrl send opencode-v2-dev ctrl-c
```
- Use `termctrl show` after each meaningful interaction and inspect the full visible screen for rendering errors, stale state, error toasts, and unexpected exits.
- Save PNG evidence for every user-visible bug and fix. Do not save text captures; inspect the rendered PNG. Write temporary captures outside the repository unless the artifact is intended to be committed.
```bash
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2-tui.png
```
- For resize-sensitive changes, resize the viewport, wait for the expected content, and capture the screen again:
```bash
termctrl resize opencode-v2-dev --cols 100 --rows 30
termctrl show opencode-v2-dev
```
- Source changes may require restarting the process. Use `termctrl restart opencode-v2-dev` rather than assuming the running TUI reloaded the change.
- To exercise background-service behavior, use `bun dev service start`, `bun dev service status`, and `bun dev service stop`.
- Always clean up the Terminal Control session when the check is complete:
```bash
termctrl stop opencode-v2-dev
```
## Comparing V2 against the legacy TUI
Run both versions in separate Terminal Control sessions and save PNG-only captures at equivalent states:
```bash
# From packages/cli: local V2 TUI
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev
# Released legacy TUI behavior reference
termctrl start opencode-legacy --host opentui --cols 112 --rows 34 -- bunx opencode-ai@latest
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2.png
termctrl save opencode-legacy --format png --out /tmp/opencode/legacy.png
```
- Use the same viewport and send equivalent inputs to both sessions before comparing screenshots. The released CLI is a behavioral reference, not a source of V2 API design; keep the local implementation on V2 endpoints.
- Stop both sessions after comparison: `termctrl stop opencode-v2-dev` and `termctrl stop opencode-legacy`.
## Server/API debugging
- Use `bun dev api --help` from `packages/cli` to inspect the API debugging command. It sends one request to the V2 server using the same daemon discovery/auth path as the CLI.
- Use `bun dev api` to introspect the server-side data backing the TUI. This is useful when debugging UI bugs: compare what the screen renders with the raw session, message, event, agent, or health data returned by the API to determine whether the bug is in the server state, the client data layer, or the TUI rendering.
- `bun dev api` accepts either an OpenAPI operation ID or a raw HTTP method plus path:
```bash
bun dev api get /health
bun dev api get /openapi.json
bun dev api <operationId> --param key=value
```
- Pass JSON request bodies with `--data`/`-d`; the command sets `content-type: application/json` automatically unless you provide a header. Add extra headers with `--header`/`-H name:value`.
- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control.
- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters.
## Auditing installed `opencode2` sessions
Installed next-channel sessions normally use `~/.local/share/opencode/opencode-next.db` and `~/.local/share/opencode/log/opencode.log`; `OPENCODE_DB` can override the database. Before calling `opencode2 api`, inspect `~/.local/state/opencode/service.json` because the command may start a daemon when none is healthy.
For a supplied `ses_...` ID, compare three sources:
- `opencode2 api get /api/session/active` and the Session/message endpoints for live server state.
- The database's ordered `event` rows for durable history.
- `packages/tui/src/context/data.tsx` and the relevant route for client projection and rendering.
Locate an uncertain database without modifying it:
```bash
SESSION=ses_...
for db in ~/.local/share/opencode/*.db; do
sqlite3 "file:$db?mode=ro" "select 1 from session where id='$SESSION' limit 1" 2>/dev/null | grep -q 1 && printf '%s\n' "$db"
done
```
## Logs
- Log files live under `~/.local/share/opencode/log/`. In a local/dev checkout the active file is `opencode-local.log`; `opencode.log` is used for non-local (released) channel installs. Both are append-only, shared across every CLI and server process on the machine.
- Each line is structured `key=value` text: `timestamp`, `level`, `run=<id>` (per-process run ID), `message`, and a `role=cli` or `role=server` tag. Use `run=` to isolate one process's activity and `role=` to separate client-side from server-side log lines, since a shared daemon interleaves many processes' output in one file.
- Tail the live file while reproducing an issue instead of guessing from stale output:
```bash
tail -f ~/.local/share/opencode/log/opencode-local.log
```
- Filter to one run or role when the file is noisy:
```bash
grep 'run=8fc3b1d5' ~/.local/share/opencode/log/opencode-local.log
grep 'role=server' ~/.local/share/opencode/log/opencode-local.log
```
- `OPENCODE_LOG_LEVEL` controls verbosity (default `INFO`); set it before starting `bun dev` or `serve` to get `DEBUG` output for a specific repro.
- `OPENCODE_PRINT_LOGS=1` additionally tees log output to stderr of the process that emitted it, which is useful when a process fails before you'd think to check the shared log file.
- `termctrl logs <session>` surfaces stdout/stderr for a Terminal Control session specifically (e.g. inspector output or startup failures before the TUI renderer starts) — use the log file above for anything emitted by a separate server/daemon process instead.
## Heap snapshots
The CLI installs a `SIGUSR1` listener on non-Windows processes in `packages/cli/src/heap.ts`. Use it to capture the installed `opencode2` server without restarting it or attaching an inspector.
1. Find the processes and inspect their roles and memory:
```bash
pgrep -a -f 'opencode2\.exe|opencode2'
ps -o pid,ppid,rss,vsz,lstart,etime,cmd -p <pid>,<pid>
```
2. Signal the process whose heap needs investigation. For shared-service memory, target the `opencode2.exe serve --service` child, not the short wrapper/TUI process:
```bash
kill -USR1 <server-pid>
```
3. Wait for `heap snapshot written` in the channel's log before opening the file. Snapshots are written to the same log directory as `heap-<pid>-<timestamp>.heapsnapshot`; writing a large heap can take several seconds and the file is incomplete until the completion message appears:
```bash
grep 'heap snapshot' ~/.local/share/opencode/log/opencode.log | tail
find ~/.local/share/opencode/log -maxdepth 1 -name 'heap-<server-pid>-*.heapsnapshot' -printf '%T@ %s %p\n' | sort -nr | head
```
Use `opencode-local.log` instead for a local/dev channel process. The log's `path=` field is authoritative.
4. Analyze the snapshot with Chrome DevTools, a V8 heap snapshot parser, or a temporary tool installed outside the repository. Start with the largest retained objects, dominators, object counts grouped by constructor/name, and retainer paths back to GC roots. Relate suspicious names and paths back to the source tree rather than treating large shallow allocations as leaks.
For command-line analysis, install tooling under `/tmp/opencode`, not in the repository. For example, MemLab can rank dominators and trace a reported heap object ID back to a GC root:
```bash
npm install --prefix /tmp/opencode/heap-analysis @memlab/cli
/tmp/opencode/heap-analysis/node_modules/.bin/memlab analyze object-size --snapshot <snapshot>
/tmp/opencode/heap-analysis/node_modules/.bin/memlab analyze shape --snapshot <snapshot>
/tmp/opencode/heap-analysis/node_modules/.bin/memlab trace --snapshot <snapshot> --node-id=<id>
```
A single snapshot explains what retains memory at one point in time, but does not by itself prove a leak. For leak confirmation, capture a baseline, perform a controlled repeated workload, allow idle cleanup/GC when possible, capture another snapshot, and compare growth and retainer paths. Also compare snapshot heap size with process RSS: a large difference can indicate native allocations, database mappings, allocator fragmentation, or other memory outside the JavaScript heap.
```bash
cat /proc/<pid>/smaps_rollup
pmap -x <pid> | sort -k3 -nr | head -25
```
Heap serialization itself can temporarily increase RSS and allocator high-water marks, so record `ps`/`smaps_rollup` both before and after capture. Large anonymous mappings with a comparatively small live heap require native-allocation or allocator investigation; they cannot be explained from JavaScript retainer paths alone.
## CPU profiles
The CLI installs a `SIGPROF` listener on non-Windows processes in `packages/cli/src/cpu-profile.ts`. One signal starts a ten-second CPU profile and stops it automatically; additional signals are ignored while a profile is active. There is no CPU profile CLI flag or environment variable.
1. Get the PID from the health endpoint. For shared-service performance, target the server PID returned here rather than the short wrapper or TUI process:
```bash
opencode2 api get /api/health
```
Use `bun dev api get /api/health` instead when targeting the local/dev channel.
2. Start the capture:
```bash
kill -PROF <server-pid>
```
3. Wait for `CPU profile written` in the channel's log before opening the file. Profiles are written to the same log directory as `cpu-<pid>-<timestamp>.cpuprofile`; the log's `path=` field is authoritative:
```bash
grep 'CPU profile' ~/.local/share/opencode/log/opencode.log | tail
find ~/.local/share/opencode/log -maxdepth 1 -name 'cpu-<server-pid>-*.cpuprofile' -printf '%T@ %s %p\n' | sort -nr | head
```
Use `opencode-local.log` for a local/dev process. Load the completed `.cpuprofile` in Chrome DevTools or another V8 CPU profile viewer and inspect the hottest functions, call stacks, and self time during the controlled workload.
## Debugger
- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL:
```bash
termctrl start opencode-v2-debug --host opentui --cols 112 --rows 34 -- \
bun run --inspect=ws://localhost:6499/ src/index.ts
```
- Use `--inspect-wait` or `--inspect-brk` when execution must pause until a debugger attaches.
- Use `termctrl logs opencode-v2-debug` for inspector output or startup failures emitted before the TUI renderer starts. Use `termctrl show` for the visible full-screen TUI.
## Verification
- Run `bun typecheck` from `packages/cli` after CLI adapter changes.
- Run `bun typecheck` and `bun test` from `packages/tui` after shared TUI changes. Do not run tests from the repository root.
- Treat automated checks and Terminal Control smoke tests as complementary. For user-visible changes, verify initial render, the changed interaction, Ctrl-C exit behavior, and save a screenshot of the corrected state.
+253
View File
@@ -0,0 +1,253 @@
---
name: opencode-drive
description: Use when an agent needs drive OpenCode via a script or interact with an isolated instance
---
# OpenCode Drive
Use `opencode-drive` to launch an isolated OpenCode instance and control it via commands or a script.
There are two modes. Always default to using a script unless specifically directed to be interactive (connect
to an existing running instance, or start a new one, and make a few changes to the UI and read it, and iterate
on changes).
Scripts allow you to run a full walkthrough in one run. When the script is done opencode-drive exits,
stops all processes, and cleans up all artifacts.
# Prepare The Environment
Use `init` when files must be added to the isolated home or project before OpenCode starts. It prints the artifact directory without launching OpenCode. A later `start` with the same name reuses it.
```bash
artifacts=$(opencode-drive init --name demo)
cp -R ./fixtures/home/. "$artifacts/"
cp -R ./fixtures/project/. "$artifacts/files/"
opencode-drive start --name demo --dev ~/projects/opencode
```
The simulated project is under `$artifacts/files`. Running `start` without a prior `init` initializes the artifacts automatically.
# Scripted usage
You can write scripts that walk through entire flows, and gives you full access to controlling
the backend too. See examples of the script API at the bottom of this file.
After creating or editing a script, always typecheck it before running. Never skip this step:
```bash
opencode-drive check ./reproduce-stale-exploring-empty.ts
```
Run it by passing `--script` to start:
```bash
opencode-drive start --name auto-stop-reproduction --script ./reproduce-stale-exploring-empty.ts
```
It will output information about the run, including paths to log files which you can read
to inspect what happened. If you need to dig into failures that aren't clear, read those log
files. If the script is unsuccessful, automatically fix the script and run it again.
Scripts use one typed definition object. `setup` runs before OpenCode starts,
and `fs.writeFile` always writes inside the simulated project.
You can read the full typed API here: https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/src/script/types.ts
```ts
import { defineScript } from "opencode-drive"
export default defineScript({
async setup({ fs, config }) {
config.autoupdate = false
await fs.writeFile("src/example.ts", "export const value = 1\n")
},
async run({ ui, llm }) {
await ui.submit("Open src/example.ts")
await llm.send(llm.text("The file exports `value`."))
await ui.waitFor("The file exports `value`.")
},
})
```
`setup` receives the current OpenCode config object, which starts from the
default drive config unless the prepared instance already has one. When a script
needs custom config, mutate this `config` parameter instead of generating and
writing a new config object from scratch, so the script keeps the default
provider/model settings unless it intentionally changes them.
Note that the simulated model is a GPT model type, and opencode uses the `patch` tool for working with files Do not use a `edit` or `write` tool to edit files.
Use `launch: "manual"` when the script needs to launch the server and every TUI
itself (this is extremely rare, do not use this unless explicitly asked). In this
mode `ui` is typed as `null`; call `server.launch()` exactly
once before launching clients. Each `clients.launch(name)` result provides the
same UI methods as the automatic client. You can see an example of this API
here: https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/multiple-clients.ts
Use the exported `wait(milliseconds)` utility for an unconditional delay.
`await llm.send(...)` waits for the next request and resolves after OpenCode
acknowledges its complete response. `llm.queue(...)` declares responses in
advance. Chunks may be built with `text`, `reasoning`, `toolCall`, `raw`,
`finish`, and `disconnect`. A normal response receives `finish("stop")`
automatically unless it yields or queues an explicit terminal event.
`llm.text(text, { delay, chunkSize })` defaults to a 2 ms delay and a
15-character target varied by plus or minus 5 per chunk.
`llm.reasoning` accepts the same options, and `llm.pause(milliseconds)` adds a
delay between any two outputs.
Use `llm.serve` for an ongoing typed response generator:
```ts
llm.serve(async function* (request, index) {
yield llm.reasoning(`Handling request ${index + 1}`)
yield llm.text(`Received ${request.id}`)
yield llm.finish("stop")
})
```
The backend connection, response cleanup, cancellation, and recording
completion are automatic.
You can see some example scripts here:
- https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/simple.ts
- https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/serve.ts
## Prune
- `prune` removes artifact directories. These are always cleaned up after running a script
successfully, but leftover on failed runs. Always call this if a script fails.
```bash
opencode-drive prune --name demo
// --force cleans up all artifcat directories
opencode-dirve prune --force
```
# Live interaction usage
- Always give headless instances a unique `--name`. Visible instances may omit it.
- A normal headless `start` detaches automatically and returns after the instance is ready.
- Do not add `&`; the long-running owner already runs in the background.
- Configure simulated model responses after startup when needed.
- Send ordered UI commands with `send`.
- Always stop the instance when finished.
```bash
opencode-drive start --name demo
opencode-drive send --name demo \
--command.ui.type '{"text":"Explain this project"}' \
--command.ui.enter
opencode-drive stop --name demo
```
## Send UI Commands
- Every `send` opens a connection to the named instance, runs its commands in order, and exits.
- Combine typing and Enter in one command when submitting a prompt.
- JSON-valued commands require one JSON argument.
- Multiple command flags execute from left to right.
Commands:
- `--command.ui.type <json>` types into the focused editor. Arguments: `text` string.
- `--command.ui.press <json>` presses a key. Arguments: `key` string; optional `modifiers` object with boolean `ctrl`, `shift`, `meta`, `super`, or `hyper`.
- `--command.ui.enter` presses Enter. Arguments: none.
- `--command.ui.arrow <json>` presses an arrow key. Arguments: `direction` is `up`, `down`, `left`, or `right`.
- `--command.ui.focus <json>` focuses an element. Arguments: `target` is the numeric element `num` returned by `ui.state`.
- `--command.ui.click <json>` clicks an element. Arguments: numeric `target`, `x`, and `y`; use the element `num` returned by `ui.state` as `target`.
- `--command.ui.state` prints focus and interactive element metadata as JSON. Arguments: none.
- `--command.ui.matches <json>` prints whether literal, case-sensitive text appears on screen. Arguments: `text` string.
```bash
opencode-drive send --name demo \
--command.ui.type '{"text":"Find the relevant code and explain it"}' \
--command.ui.enter
opencode-drive send --name demo \
--command.ui.press '{"key":"p","modifiers":{"ctrl":true}}'
opencode-drive send --name demo \
--command.ui.arrow '{"direction":"down"}'
opencode-drive send --name demo \
--command.ui.focus '{"target":12}'
opencode-drive send --name demo \
--command.ui.click '{"target":12,"x":4,"y":1}'
opencode-drive send --name demo \
--command.ui.matches '{"text":"OpenCode"}'
```
To read the UI state and see information about interactable elements, use the `ui.state` command:
```bash
opencode-drive send --name demo --command.ui.state
```
## Configure LLM Responses
- `responses` controls what the LLM responds with
- Only use this if you are wanting to reproduce an exact type of response
- Defaults are `text,reasoning,diff,tool` with `write,apply_patch`.
- Supported types are `text`, `reasoning`, `diff`, and `tool`.
- `--tools` limits generated tool calls to names offered by OpenCode.
```bash
opencode-drive responses --name demo \
--types text,reasoning,diff,tool \
--tools write,apply_patch
opencode-drive responses --name demo \
--types tool \
--tools read,glob,grep
```
## Inspect The UI
- `ui.state` prints focus and interactive element metadata as JSON.
- `ui.matches` checks for literal, case-sensitive screen text.
- `screenshot` prints the generated image path.
```bash
opencode-drive screenshot --name demo
```
## Lifecycle
- `stop` waits for recording export and owner cleanup before returning.
```bash
opencode-drive stop --name demo
```
# Record The UI
- Start with `--record` to capture a headless instance from its first rendered frame.
- `stop` finishes the recording, exports an MP4, and prints its path.
```bash
opencode-drive start --name demo --record
opencode-drive send --name demo \
--command.ui.type '{"text":"Show me the current architecture"}' \
--command.ui.enter
opencode-drive stop --name demo
```
# Artifacts dir
- `dir` prints the artifact directory for the instance.
```bash
opencode-drive dir --name demo
```
+1 -1
View File
@@ -1,5 +1,5 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode/plugin"
import { tool } from "@opencode-ai/plugin"
async function githubFetch(endpoint: string, options: RequestInit = {}) {
const response = await fetch(`https://api.github.com${endpoint}`, {
...options,
+1 -1
View File
@@ -1,5 +1,5 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode/plugin"
import { tool } from "@opencode-ai/plugin"
const TEAM = {
tui: ["kommander", "simonklee"],
+17 -1
View File
@@ -1,3 +1,19 @@
{
"$schema": "https://opencode.ai/tui.json"
"$schema": "https://opencode.ai/tui.json",
"plugin": [
[
"./plugins/tui-smoke.tsx",
{
"enabled": false,
"label": "workspace",
"keybinds": {
"smoke_modal": "ctrl+alt+m",
"smoke_screen": "ctrl+alt+o",
"smoke_screen_home": "escape,ctrl+shift+h",
"smoke_screen_modal": "ctrl+alt+m",
"smoke_dialog_close": "escape,q"
}
}
]
]
}
+44 -108
View File
@@ -1,115 +1,51 @@
{
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schema/src/public/.oxlintrc.schema.json",
"jsPlugins": [
{ "name": "anti-slop", "specifier": "./script/oxlint/anti-slop/index.ts" },
{ "name": "anti-slop-effect", "specifier": "./script/oxlint/anti-slop/effect/index.ts" }
],
"options": {
"typeAware": true
},
"categories": {
"correctness": "off",
"suspicious": "off",
"pedantic": "off",
"perf": "off",
"style": "off",
"restriction": "off",
"nursery": "off"
"suspicious": "warn"
},
"rules": {
"no-restricted-globals": [
"error",
{
"name": "Reflect",
"message": "Use typed property access or direct invocation. Suppress this rule only for genuine reflection."
}
]
"typescript/no-base-to-string": "warn",
// Effect uses `function*` with Effect.gen/Effect.fnUntraced that don't always yield
"require-yield": "off",
// SolidJS uses `let ref: T | undefined` for JSX ref bindings assigned at runtime
"no-unassigned-vars": "off",
// SolidJS tracks reactive deps by reading properties inside createEffect
"no-unused-expressions": "off",
// Intentional control char matching (ANSI escapes, null byte sanitization)
"no-control-regex": "off",
// SST and plugin tools require triple-slash references
"triple-slash-reference": "off",
// Suspicious category: suppress noisy rules
// Effect's nested function* closures inherently shadow outer scope
"no-shadow": "off",
// Namespace-heavy codebase makes this too noisy
"unicorn/consistent-function-scoping": "off",
// Opinionated — .sort()/.reverse() mutation is fine in this codebase
"unicorn/no-array-sort": "off",
"unicorn/no-array-reverse": "off",
// Not relevant — this isn't a DOM event handler codebase
"unicorn/prefer-add-event-listener": "off",
// Bundler handles module resolution
"unicorn/require-module-specifiers": "off",
// postMessage target origin not relevant for this codebase
"unicorn/require-post-message-target-origin": "off",
// Side-effectful constructors are intentional in some places
"no-new": "off",
// Type-aware: catch unhandled promises
"typescript/no-floating-promises": "warn",
// Warn when spreading non-plain objects (Headers, class instances, etc.)
"typescript/no-misused-spread": "warn"
},
"overrides": [
{
"files": [
"packages/app/**",
"packages/desktop/**",
"packages/gui-extensions/**",
"packages/ui/**",
"packages/session-ui/**"
],
"rules": {
"oxc/no-accumulating-spread": "warn",
"anti-slop/no-array-filter-map": "warn",
"anti-slop/no-reduce-accumulator-copy": "warn",
"anti-slop/no-chained-type-assertions": "warn",
"anti-slop/no-conditional-empty-object-spread": "warn",
"anti-slop/no-known-value-widening": "warn",
"anti-slop/no-module-mocking": "warn",
"anti-slop/no-object-parameters": "warn",
"anti-slop/no-reflect-apply": "warn",
"anti-slop/no-reflect-get": "warn",
"anti-slop/no-runtime-typeof": "warn",
"anti-slop/no-shape-in-symbol-names": "warn",
"anti-slop/no-unknown-parameters": "warn",
"anti-slop/no-unknown-returns": "warn",
"anti-slop/no-unknown-type-aliases": "warn",
"anti-slop/no-unsafe-dictionary-type": "warn",
"anti-slop/no-widen-then-assert": "warn",
"anti-slop/require-readable-spacing": "warn",
"anti-slop/require-safety-comment-for-type-assertion": "warn"
}
},
{
"files": ["packages/app/**", "packages/desktop/**", "packages/gui-extensions/**", "packages/session-ui/**"],
"rules": {
"anti-slop-effect/no-manual-effect-error-tag": "warn",
"anti-slop-effect/no-manual-tag-comparison": "warn",
"anti-slop-effect/no-manual-tagged-construction": "warn",
"anti-slop-effect/no-service-constructor-imports": "warn",
"anti-slop-effect/prefer-effect-match": "warn"
}
},
{
"files": ["packages/gui-extensions/src/*/**"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"regex": "^@opencode/(app|desktop)(/|$)",
"message": "GUI extensions never import the app or desktop packages. Use the SDK."
},
{
"regex": "^@/",
"message": "GUI extensions never import app internals. Use the SDK."
},
{
"group": ["../*/*", "!../*/contract", "!../sdk/*"],
"message": "Import another extension only through its contract.ts."
},
{
"regex": "\\.css$",
"message": "Import CSS with ?inline and contribute it with ctx.add(Style, css)."
}
]
}
]
}
}
],
"ignorePatterns": [
"**/node_modules",
"**/dist",
"**/.build",
"**/.sst",
"**/*.d.ts",
"**/sdk.gen.ts",
".agent/**",
".agents/**",
".claude/**",
".codex/**",
".continue/**",
".cursor/**",
".gemini/**",
".opencode/**",
".pi/**",
".roo/**",
".windsurf/**",
"script/oxlint/anti-slop/**"
]
"options": {
"typeAware": true
},
"options": {
"typeAware": true
},
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts", "**/sdk.gen.ts"]
}
+18 -36
View File
@@ -1,32 +1,17 @@
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit generated client files directly.
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk` composes Client, Core, and Server.
- Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
- This repository does not use Changesets. Do not add `.changeset` files; follow the existing release workflow instead.
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
- The default branch in this repo is `v2`.
- Default new branches and worktrees to `v2`, or `origin/v2` when the local `v2` ref is unavailable, and default pull requests to target `v2`. Use another base or target branch when the requester explicitly instructs it.
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
- Local `main` ref may not exist; use `v2` or `origin/v2` for diffs.
## Live V2 TUI Testing
- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode` background server and live sessions.
- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode2` background server and live sessions.
- Pass a directory after the script when needed, for example `bun run dev:live /path/to/project`.
- The script discovers the server with `opencode service status`, injects its private local credential from `opencode service get password`, and uses the `dev` TUI storage channel so tabs and other client-local state match the installed client.
- The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `next` TUI storage channel so tabs and other client-local state match the installed client.
- Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it.
## V2 TUI Stories
- When a user asks for a TUI story, add a fixture-driven story under `packages/tui/src/feature-plugins/system/storybook` and register it in `index.tsx`.
- Render the real production component rather than a visual copy. Keep submissions and other side effects local to the story so it is safe to explore repeatedly.
- Expose the meaningful state dimensions through story keybindings and list them in `StoryFooter`; include a reset command when combinations can leave the fixture in a confusing state.
- Run a specific story with `OPENCODE_STORY=<story-id> bun run dev:live` from the development worktree, and exercise narrow and wide terminal sizes when layout is relevant.
## TUI Theme Tokens
- Choose theme tokens by semantic role, not by their current color. Do not use raw `theme.hue` values or borrow an unrelated semantic token to achieve a preferred appearance.
- Use `text.feedback` and `background.feedback` only for outcome or status feedback such as errors, warnings, success messages, and informational messages. Use `formfield` states for form-control text, ordinals, and selection markers, and `action` states for actions.
- If the theme does not expose a token for the required semantic role, extend the theme schema, defaults, resolution, and types with that role before using it in a component. Do not repurpose the nearest-looking existing token.
- When changing the public theme token surface, verify the built-in light and dark defaults and the custom-theme fallback path in addition to the affected TUI component.
## Branch Names
Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`.
@@ -46,7 +31,6 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi
### General Principles
- Keep things in one function unless composable or reusable
- Validate unknown values once at the boundary that owns them. Pass typed values inward instead of repeating `typeof value === "object"` and property-existence checks. Do not defensively revalidate values already guaranteed by a schema, constructor, or internal type.
- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller.
- Before adding complexity for a speculative or vanishingly unlikely race or security edge case, explain the concrete failure mode, likelihood, and complexity cost to the user and get their buy-in. Do not silently expand scope for theoretical robustness.
- Avoid `try`/`catch` where possible
@@ -84,9 +68,9 @@ const { a, b } = obj
### Imports
- Never alias imports. Do not use `import { foo as bar } from "..."` or renamed imports like `resolve as pathResolve`.
- Never use type-position `import("...")` references such as `Schema.declare<import("@opencode/plugin/effect/plugin").Plugin["effect"]>`. Only when two imports genuinely collide on a name and no other option exists, an aliased type import (`import type { Plugin as PluginDefinition } from "..."`) is permitted as a last resort — still strongly preferred not to.
- Never use type-position `import("...")` references such as `Schema.declare<import("@opencode-ai/plugin/effect/plugin").Plugin["effect"]>`. Only when two imports genuinely collide on a name and no other option exists, an aliased type import (`import type { Plugin as PluginDefinition } from "..."`) is permitted as a last resort — still strongly preferred not to.
- Never use star imports. Do not use `import * as Foo from "..."` or `import type * as Foo from "..."`.
- If a namespace-style value is needed, import the module's own exported namespace by name, for example `import { Project } from "@opencode/core/project"`, then reference `Project.ID`.
- If a namespace-style value is needed, import the module's own exported namespace by name, for example `import { Project } from "@opencode-ai/core/project"`, then reference `Project.ID`.
- Prefer dynamic imports for heavy modules that are only needed in selected code paths, especially in startup-sensitive entrypoints. Destructure dynamic import bindings near the top of the narrowest scope that needs them so they read like normal imports. Avoid inline chains such as `await import("./module").then((mod) => mod.value())` or `(await import("./module")).value()`. Keep branch-specific imports inside the branch that needs them to preserve lazy loading.
### Variables
@@ -168,25 +152,23 @@ const table = sqliteTable("session", {
- Avoid mocks as much as possible, you shouldn't be using globalThis.\* at all unless it's the only option.
- Test actual implementation, do not duplicate logic into tests
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package directories such as `packages/core`.
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
## Checks
## Type Checking
- Run `bun run check` from the repository root as the canonical full lint and type-check verification.
- During focused iteration, run `bun typecheck` from the affected package directory (for example, `packages/core`). Never run `tsc` directly.
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
## V2 Session Core
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
- Keep durable prompt admission separate from model execution. `Session.prompt(...)` publishes `session.inbox.enqueued`, whose projection inserts one durable `session_inbox` row, before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. Delivery publishes `session.inbox.delivered`; its projection consumes the inbox row and inserts the visible message in the same transaction. `session_inbox` stores only unconsumed work.
- Reusing a Session ID adopts the existing Session. Reusing a user or synthetic inbox item ID is idempotent when Session and type match: the first admission wins and the retried payload, metadata, and delivery mode are ignored, whether the item is still pending or already delivered (reconciled from the projected message without retained enqueue history). Cross-Session or cross-type reuse fails. Control items keep their operation-specific conflict behavior.
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_pending` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries, consuming the pending row in the same event transaction; `session_pending` stores only unconsumed work.
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Retry of an already-promoted input reconciles against the projected message and the durable admitted event rather than a retained row.
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
- Keep provider-specific native compaction mechanisms in `@opencode/ai` behind `LLMClient.compact`. `SessionCompaction` chooses a summary or native compaction from the model's `compaction` setting and owns route provenance, request shrinking, the retry policy, interruption, usage accounting, and checkpoint persistence.
- Keep delivery vocabulary explicit. Prompts steer by default. At safe step boundaries, steered compaction takes priority up to the first steered move control; other steers retain enqueue order. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. Most Steps have one Physical Attempt; overflow-triggered compaction recovery may rebuild one Step for a second attempt. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe step boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's step allowance; a batch of steers resets it once.
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
- Keep event replay ownership separate from clustered Session execution ownership.
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
- `session.instructions.updated` stores changed source keys and content hashes and may freeze rendered chronological update text. Blob values live once in `instruction_blob`; the projected `instruction_state` row is the normal boundary-processing source of current and initial values. Request assembly renders the epoch baseline from stored values, while later frozen updates enter history as durable System messages. Completed compaction moves the instruction epoch; Session movement retains it so destination instruction changes are chronological, while committed revert clears it. Forks adopt the parent's newest instruction values even when copied message history ends at an earlier boundary. Unavailable sources retain the last value and block only the initial complete delta.
- `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement retains it so destination instruction changes are chronological, while committed revert clears it. Unavailable sources retain the last value and block only the initial complete delta.
+223 -63
View File
@@ -1,112 +1,272 @@
# Contributing to OpenCode
The changes most likely to be accepted are:
We want to make it easy for you to contribute to OpenCode. Here are the most common type of changes that get merged:
- Bug fixes
- Additional LSPs and formatters
- LLM performance improvements
- Environment-specific fixes
- Additional LSPs / Formatters
- Improvements to LLM performance
- Support for new providers
- Fixes for environment-specific quirks
- Missing standard behavior
- Documentation improvements
UI and core product features require design review before implementation. If you are unsure whether a change fits, ask a maintainer or choose an issue labeled [`help wanted`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Ahelp-wanted), [`good first issue`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22), [`bug`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug), or [`perf`](https://github.com/anomalyco/opencode/issues?q=is%3Aopen%20is%3Aissue%20label%3A%22perf%22).
However, any UI or core product feature must go through a design review with the core team before implementation.
Want to take on an issue? Leave a comment and a maintainer may assign it unless it is already being worked on.
If you are unsure if a PR would be accepted, feel free to ask a maintainer or look for issues with any of the following labels:
- [`help wanted`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Ahelp-wanted)
- [`good first issue`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22)
- [`bug`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug)
- [`perf`](https://github.com/anomalyco/opencode/issues?q=is%3Aopen%20is%3Aissue%20label%3A%22perf%22)
> [!NOTE]
> PRs that ignore these guardrails will likely be closed.
## Adding Providers
Want to take on an issue? Leave a comment and a maintainer may assign it to you unless it is something we are already working on.
New providers should rarely require OpenCode changes. Add the provider to [models.dev](https://github.com/anomalyco/models.dev) first.
## Adding New Providers
## Development
New providers shouldn't require many if ANY code changes, but if you want to add support for a new provider first make a PR to:
https://github.com/anomalyco/models.dev
OpenCode requires Bun 1.3 or newer. From the repository root:
## Developing OpenCode
- Requirements: Bun 1.3+
- Install dependencies and start the dev server from the repo root:
```bash
bun install
bun dev
```
### Running against a different directory
By default, `bun dev` runs OpenCode in the `packages/opencode` directory. To run it against a different directory or repository:
```bash
bun install
bun dev [directory]
bun dev <directory>
```
`bun dev` runs the V2 CLI and TUI. Pass a directory to open another project, or `.` to open this repository.
To test a development TUI against your installed OpenCode V2 background service and live sessions:
To run OpenCode in the root of the opencode repo itself:
```bash
bun run dev:live [directory]
bun dev .
```
For web development, run the backend and app in separate terminals. Other interfaces have root scripts:
### Building a "localcode"
To compile a standalone executable:
```bash
bun dev serve --port 4096
bun run dev:web
bun run dev:desktop
bun run dev:www
./packages/opencode/script/build.ts --single
```
### Packages
- `packages/schema`: shared wire and storage contracts
- `packages/core`: domain behavior and persistence
- `packages/protocol`: public API definitions
- `packages/server`: HTTP server and runtime composition
- `packages/client`: generated TypeScript clients
- `packages/cli`: command-line entrypoint and service lifecycle
- `packages/tui`: terminal interface
- `packages/app`: shared web interface
- `packages/desktop`: Electron desktop application
- `packages/plugin`: plugin API
### Verification
Run typechecks, and tests where defined, from the affected package rather than the repository root:
Then run it with:
```bash
cd packages/core
bun run test
bun typecheck
./packages/opencode/dist/opencode-<platform>/bin/opencode
```
Follow package-specific instructions in nearby `AGENTS.md` files. After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`; never edit generated client files directly.
Replace `<platform>` with your platform (e.g., `darwin-arm64`, `linux-x64`).
Follow the repository [style guide](./AGENTS.md).
- Core pieces:
- `packages/opencode`: OpenCode core business logic & server.
- `packages/opencode/src/cli/cmd/tui/`: The TUI code, written in SolidJS with [opentui](https://github.com/sst/opentui)
- `packages/app`: The shared web UI components, written in SolidJS
- `packages/desktop`: The native desktop app, built with Electron (wraps `packages/app`)
- `packages/plugin`: Source for `@opencode-ai/plugin`
## Pull Requests
### Understanding bun dev vs opencode
### Link Issues When Required
During development, `bun dev` is the local equivalent of the built `opencode` command. Both run the same CLI interface:
Bug fixes, chores, and tests must reference an existing issue. Documentation, refactor, and feature PRs are exempt from the automated linked-issue check. When required, use `Fixes #123` or `Closes #123` in the PR description.
```bash
# Development (from project root)
bun dev --help # Show all available commands
bun dev serve # Start headless API server
bun dev web # Start server + open web interface
bun dev <directory> # Start TUI in specific directory
Before implementing new functionality, open a feature request describing the problem, why it belongs in OpenCode, and your proposed approach if you have one. Wait for design approval before opening the implementation PR.
# Production
opencode --help # Show all available commands
opencode serve # Start headless API server
opencode web # Start server + open web interface
opencode <directory> # Start TUI in specific directory
```
Base branches on `v2`, not `dev`, and complete the provided pull request template.
### Running the API Server
### Keep It Focused
To start the OpenCode headless API server:
- Keep PRs small and focused.
- Explain the problem and why the change fixes it.
- Check whether the functionality already exists.
- For UI changes, include before-and-after screenshots or video.
- For logic changes, explain what you tested and how a reviewer can verify it.
```bash
bun dev serve
```
### Keep It Brief
This starts the headless server on port 4096 by default. You can specify a different port:
Long, AI-generated PR descriptions and issues may be ignored. Write a short explanation in your own words. If the change cannot be explained briefly, the PR may be too large.
```bash
bun dev serve --port 8080
```
### Use Conventional Titles
### Running the Web App
Use `type(scope): summary`. Supported types are `feat`, `fix`, `docs`, `chore`, `refactor`, and `test`. The scope is optional.
To test UI changes during development:
1. **First, start the OpenCode server** (see [Running the API Server](#running-the-api-server) section above)
2. **Then run the web app:**
```bash
bun run --cwd packages/app dev
```
This starts a local dev server at http://localhost:5173 (or similar port shown in output). Most UI changes can be tested here, but the server must be running for full functionality.
### Running the Desktop App
The desktop app is an Electron application that wraps the web UI.
To run the desktop app in development:
```bash
bun run --cwd packages/desktop dev
```
To create a production build and package the app:
```bash
bun run --cwd packages/desktop build
bun run --cwd packages/desktop package
```
> [!NOTE]
> If you make changes to the API or SDK (e.g. `packages/opencode/src/server/server.ts`), run `./script/generate.ts` to regenerate the SDK and related files.
Please try to follow the [style guide](./AGENTS.md)
### Setting up a Debugger
Bun debugging is currently rough around the edges. We hope this guide helps you get set up and avoid some pain points.
The most reliable way to debug OpenCode is to run it manually in a terminal via `bun run --inspect=<url> dev ...` and attach
your debugger via that URL. Other methods can result in breakpoints being mapped incorrectly, at least in VSCode (YMMV).
Caveats:
- If you want to run the OpenCode TUI and have breakpoints triggered in the server code, you might need to run `bun dev spawn` instead of
the usual `bun dev`. This is because `bun dev` runs the server in a worker thread and breakpoints might not work there.
- If `spawn` does not work for you, you can debug the server separately:
- Debug server: `bun run --inspect=ws://localhost:6499/ --cwd packages/opencode ./src/index.ts serve --port 4096`,
then attach TUI with `opencode attach http://localhost:4096`
- Debug TUI: `bun run --inspect=ws://localhost:6499/ --cwd packages/opencode --conditions=browser ./src/index.ts`
Other tips and tricks:
- You might want to use `--inspect-wait` or `--inspect-brk` instead of `--inspect`, depending on your workflow
- Specifying `--inspect=ws://localhost:6499/` on every invocation can be tiresome, you may want to `export BUN_OPTIONS=--inspect=ws://localhost:6499/` instead
#### VSCode Setup
If you use VSCode, you can use our example configurations [.vscode/settings.example.json](.vscode/settings.example.json) and [.vscode/launch.example.json](.vscode/launch.example.json).
Some debug methods that can be problematic:
- Debug configurations with `"request": "launch"` can have breakpoints incorrectly mapped and thus unusable
- The same problem arises when running OpenCode in the VSCode `JavaScript Debug Terminal`
With that said, you may want to try these methods, as they might work for you.
## Pull Request Expectations
### Issue First Policy
**All PRs must reference an existing issue.** Before opening a PR, open an issue describing the bug or feature. This helps maintainers triage and prevents duplicate work. PRs without a linked issue may be closed without review.
- Use `Fixes #123` or `Closes #123` in your PR description to link the issue
- For small fixes, a brief issue is fine - just enough context for maintainers to understand the problem
### General Requirements
- Keep pull requests small and focused
- Explain the issue and why your change fixes it
- Before adding new functionality, ensure it doesn't already exist elsewhere in the codebase
### UI Changes
If your PR includes UI changes, please include screenshots or videos showing the before and after. This helps maintainers review faster and gives you quicker feedback.
### Logic Changes
For non-UI changes (bug fixes, new features, refactors), explain **how you verified it works**:
- What did you test?
- How can a reviewer reproduce/confirm the fix?
### No AI-Generated Walls of Text
Long, AI-generated PR descriptions and issues are not acceptable and may be ignored. Respect the maintainers' time:
- Write short, focused descriptions
- Explain what changed and why in your own words
- If you can't explain it briefly, your PR might be too large
### PR Titles
PR titles should follow conventional commit standards:
- `feat:` new feature or functionality
- `fix:` bug fix
- `docs:` documentation or README changes
- `chore:` maintenance tasks, dependency updates, etc.
- `refactor:` code refactoring without changing behavior
- `test:` adding or updating tests
You can optionally include a scope to indicate which package is affected:
- `feat(app):` feature in the app package
- `fix(desktop):` bug fix in the desktop package
- `chore(opencode):` maintenance in the opencode package
Examples:
- `docs: update contributing guide`
- `fix(tui): restore scroll position`
- `feat(app): add workspace search`
- `docs: update contributing guidelines`
- `fix: resolve crash on startup`
- `feat: add dark mode support`
- `feat(app): add dark mode support`
- `fix(desktop): resolve crash on startup`
- `chore: bump dependency versions`
## Issues
### Style Preferences
Bug reports and feature requests must use their issue templates. Blank issues are not allowed; ask support and how-to questions in the [Discord community](https://discord.gg/opencode).
These are not strictly enforced, they are just general guidelines:
Automated checks flag missing templates, placeholder text, AI-generated walls of text, and missing meaningful content. You have two hours to correct a flagged issue before it closes automatically. Ask a maintainer if an issue was flagged incorrectly.
- **Functions:** Keep logic within a single function unless breaking it out adds clear reuse or composition benefits.
- **Destructuring:** Do not do unnecessary destructuring of variables.
- **Control flow:** Avoid `else` statements.
- **Error handling:** Prefer `.catch(...)` instead of `try`/`catch` when possible.
- **Types:** Reach for precise types and avoid `any`.
- **Variables:** Stick to immutable patterns and avoid `let`.
- **Naming:** Choose concise single-word identifiers when they remain descriptive.
- **Runtime APIs:** Use Bun helpers such as `Bun.file()` when they fit the use case.
## Feature Requests
For net-new functionality, start with a design conversation. Open an issue describing the problem, your proposed approach (optional), and why it belongs in OpenCode. The core team will help decide whether it should move forward; please wait for that approval instead of opening a feature PR directly.
## Issue Requirements
All issues **must** use one of our issue templates:
- **Bug report** — for reporting bugs (requires a description)
- **Feature request** — for suggesting enhancements (requires verification checkbox and description)
- **Question** — for asking questions (requires the question)
Blank issues are not allowed. When a new issue is opened, an automated check verifies that it follows a template and meets our contributing guidelines. If an issue doesn't meet the requirements, you'll receive a comment explaining what needs to be fixed and have **2 hours** to edit the issue. After that, it will be automatically closed.
Issues may be flagged for:
- Not using a template
- Required fields left empty or filled with placeholder text
- AI-generated walls of text
- Missing meaningful content
If you believe your issue was incorrectly flagged, let a maintainer know.
-279
View File
@@ -1,279 +0,0 @@
# V2 HTTP API audit checklist
**Source:** `packages/protocol/openapi.json`
**Current endpoint count:** 139
**Last regenerated:** 2026-09-13
## How to use this checklist
Review endpoints in document order. For each endpoint, select one disposition and capture rationale or follow-up work in Notes. Mark **Reviewed** only after the disposition is agreed.
### Review criteria
- Resource and operation naming
- HTTP method and idempotency
- Request parameters and location scope
- Response shape and error taxonomy
- Authentication and authorization
- Current production consumers
- Stability level: public, experimental, or internal
- Whether the generated client API is intuitive
### Disposition legend
- **Keep:** ship unchanged as a supported V2 API
- **Change:** retain after a defined contract change
- **Remove:** exclude from the official V2 API
- **Experimental-only:** retain outside the stable API commitment
## Progress
- [x] Group 1: Foundation and placement (4)
- [x] Group 2: Configuration and capability catalogs (16)
- [x] Group 3: Credentials, integrations, MCP, and web search (22)
- [x] Group 4: Session lifecycle (12)
- [x] Group 5: Session execution and inputs (11)
- [x] Group 6: Session history and recovery (13)
- [x] Group 7: Inbox, permissions, and forms (19)
- [x] Group 8: Filesystem, worktrees, and VCS (12)
- [x] Group 9: PTYs, persistent terminals, and shells (24)
- [x] Group 10: Events, RPC, and experimental operations (6)
## Resolved during audit
### [x] `POST /api/plugin/await-activation`
- **Decision:** Remove
- **Notes:** Activation timing is an internal server concern. Catalog reads remain non-blocking.
### [x] Location response wrappers
- **Decision:** Reduce generic endpoint response locations to `{ directory }`.
- **Notes:** Full project metadata remains available from `GET /api/location`; no consumers used it from wrapped responses.
### [x] `GET /api/health` and `GET /api/server`
- **Decision:** Merge and rename
- **Replacement:** `GET /api/info` with operation ID `server.info`.
- **Notes:** Returns `version`, `pid`, and connection `urls`; readiness is conveyed by HTTP status.
### [x] `GET /api/project/current`
- **Decision:** Remove
- **Replacement:** `GET /api/location`, using `project` from the response.
- **Notes:** The endpoint duplicated `Location.Info.project`; production callers were migrated.
### [x] `POST /api/workspace` and `DELETE /api/workspace/{workspaceID}`
- **Decision:** Remove
- **Notes:** Provider-backed workspaces are not part of the V2 HTTP contract and can be introduced later. Core and the embedded SDK retain internal workspace support.
## Group 1: Foundation and placement
**Endpoints:** 4
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 001–002 | `GET` | `/api/info` | `server.info` | Keep | Replaces the former health and server endpoints. |
| [x] 003 | `GET` | `/api/location` | `location.get` | Keep | Workspace selectors and response fields removed until workspace support ships. |
| [x] 004 | `GET` | `/api/project` | `project.list` | Keep | Removed unused `time.initialized`; the database column remains for migration data. |
| [x] 005 | `PATCH` | `/api/project/{projectID}` | `project.update` | Keep | Request and response accepted as-is. |
## Group 2: Configuration and capability catalogs
**Endpoints:** 16
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 008 | `GET` | `/api/agent` | `agent.list` | Keep | Request and response accepted as-is. |
| [x] 009 | `GET` | `/api/agent/{agentID}` | `agent.get` | Keep | Request, response, and not-found error accepted as-is. |
| [x] 010 | `GET` | `/api/plugin` | `plugin.list` | Keep | Request and response accepted as-is. |
| [x] 012 | `POST` | `/api/plugin/check` | `plugin.check` | Keep | Request and response accepted as-is. |
| [x] 013 | `POST` | `/api/plugin/update` | `plugin.update` | Keep | Request and errors accepted as-is. |
| [x] 014 | `GET` | `/api/model` | `model.list` | Keep | Request and response accepted as-is. |
| [x] 015 | `GET` | `/api/model/default` | `model.default` | Keep | Request and nullable response accepted as-is. |
| [x] 016 | `GET` | `/api/provider` | `provider.list` | Keep | Request and response accepted as-is. |
| [x] 017 | `GET` | `/api/provider/{providerID}` | `provider.get` | Keep | Request, response, and not-found error accepted as-is. |
| [x] 018 | `GET` | `/api/command` | `command.list` | Keep | Request and response accepted as-is. |
| [x] 019 | `GET` | `/api/skill` | `skill.list` | Keep | Renamed `location` to `path`; removed the skill-specific `slash` flag and slash-command behavior. |
| [x] 020 | `GET` | `/api/reference` | `reference.list` | Keep | Removed duplicate `description` and `hidden` fields from nested `source`. |
| [x] 021 | `GET` | `/api/config` | `config.get` | Keep | Compatibility entries removed; response now contains only documents and OpenCode directories. |
| [x] 022 | `GET` | `/api/config/preferences` | `config.preferences` | Remove | Redundant special projection of global config. |
| [x] 023 | `PATCH` | `/api/config/preferences` | `config.updatePreferences` | Remove | Redundant field-specific config mutation API. |
| [x] 024 | `GET` | `/api/config/shell` | `config.shells` | Keep | Required by the server Terminal shell setting. |
| [x] 024a | `PATCH` | `/api/experimental/config` | `experimental.config.update` | Change | Experimental global config mutation; initially accepts only `shell`. |
## Group 3: Credentials, integrations, MCP, and web search
**Endpoints:** 22
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 025 | `GET` | `/api/integration` | `integration.list` | Keep | Full integration inventory is consumed by authentication and integration-selection clients. |
| [x] 026 | `GET` | `/api/integration/{integrationID}` | `integration.get` | Change | Missing integration now returns typed `404` instead of optional data. |
| [x] 027 | `POST` | `/api/experimental/integration/wellknown` | `experimental.integration.wellknown.add` | Experimental-only | Retained outside the stable API commitment. |
| [x] 028 | `POST` | `/api/integration/{integrationID}/connect/key` | `integration.connect.key` | Change | Missing integration returns typed `404`; key form answers retained. |
| [x] 029 | `POST` | `/api/integration/{integrationID}/connect/oauth` | `integration.oauth.connect` | Keep | OAuth connection start contract retained. |
| [x] 030 | `GET` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.status` | Change | Missing integration or OAuth attempt returns typed `404`. |
| [x] 031 | `DELETE` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.cancel` | Keep | Idempotent cancellation remains a no-op for unavailable or terminal attempts. |
| [x] 032 | `POST` | `/api/integration/{integrationID}/connect/oauth/{attemptID}/complete` | `integration.oauth.complete` | Change | Missing integration or OAuth attempt returns typed `404`; code remains mode-dependent. |
| [x] 033 | `POST` | `/api/integration/{integrationID}/connect/command` | `integration.command.connect` | Change | Missing integration or command method returns typed `404`. |
| [x] 034 | `GET` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.status` | Change | Missing integration or command attempt returns typed `404`. |
| [x] 035 | `DELETE` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.cancel` | Keep | Idempotent cancellation remains a no-op for unavailable or terminal attempts. |
| [x] 036 | `GET` | `/api/mcp` | `mcp.list` | Keep | MCP inventory and connection status retained. |
| [x] 037 | `PUT` | `/api/experimental/mcp/{server}` | `experimental.mcp.add` | Experimental-only | Runtime-only MCP override; does not persist configuration. |
| [x] 038 | `DELETE` | `/api/experimental/mcp/{server}` | `experimental.mcp.remove` | Experimental-only | Runtime removal override; missing server returns `404`. |
| [x] 039 | `POST` | `/api/experimental/mcp/{server}/connect` | `experimental.mcp.connect` | Experimental-only | Runtime connection override retained outside the stable API. |
| [x] 040 | `POST` | `/api/experimental/mcp/{server}/disconnect` | `experimental.mcp.disconnect` | Experimental-only | Runtime disconnection override retained outside the stable API. |
| [x] 041 | `GET` | `/api/mcp/resource` | `mcp.resource.catalog` | Keep | Reviewed separately by coworker. |
| [x] 042 | `PATCH` | `/api/credential/{credentialID}` | `credential.update` | Change | Removed redundant location query; credentials and events are global. |
| [x] 043 | `DELETE` | `/api/credential/{credentialID}` | `credential.remove` | Change | Removed redundant location query; credentials and events are global. |
| [x] 044 | `POST` | `/api/credential/{credentialID}/activate` | `credential.activate` | Change | Removed redundant location query; credentials and events are global. |
| [x] 045 | `GET` | `/api/websearch/provider` | `websearch.providers` | Keep | Provider availability remains location-scoped; singular resource path retained. |
| [x] 046 | `POST` | `/api/websearch` | `websearch.query` | Keep | Unknown provider remains an invalid request; published time documented as Unix epoch milliseconds. |
## Group 4: Session lifecycle
**Endpoints:** 12
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 047 | `GET` | `/api/session` | `session.list` | Keep | Existing filtering, ordering, and cursor contract retained for now. |
| [x] 048 | `POST` | `/api/session` | `session.create` | Keep | Existing creation contract retained; model reference includes optional variant. |
| [x] 049 | `GET` | `/api/experimental/session/stats` | `experimental.session.stats` | Experimental-only | Session analytics retained outside the stable API commitment. |
| [x] 050 | `GET` | `/api/session/active` | `session.active` | Keep | Status record retained for future active-state expansion. |
| [x] 051 | `GET` | `/api/session/{sessionID}` | `session.get` | Keep | Specific session read and typed `404` retained. |
| [x] 052 | `DELETE` | `/api/session/{sessionID}` | `session.remove` | Keep | Session and child deletion with typed `404` retained. |
| [x] 053 | `POST` | `/api/session/{sessionID}/fork` | `session.fork` | Change | Request now accepts optional branded `before` message ID; omission copies full history. |
| [x] 054 | `POST` | `/api/session/{sessionID}/agent` | `session.switchAgent` | Keep | Subsequent-execution agent selection retained. |
| [x] 055 | `POST` | `/api/session/{sessionID}/model` | `session.switchModel` | Keep | Subsequent-execution model and optional variant selection retained. |
| [x] 056 | `PATCH` | `/api/session/{sessionID}` | `session.update` | Change | General session patch updates title and permissions; rules emit `session.permissions`. |
| [x] 057 | `POST` | `/api/session/{sessionID}/move` | `session.move` | Change | Removed inaccurate local-change transfer claim; delivery behavior retained. |
| [x] 058 | `POST` | `/api/session/{sessionID}/background` | `session.background` | Keep | Backgroundable foreground tools transition to background observation; idle requests remain no-ops. |
## Group 5: Session execution and inputs
**Endpoints:** 11
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 059 | `POST` | `/api/session/{sessionID}/prompt` | `session.prompt` | Keep | Durable admission, delivery mode, and admit-only resume control retained. |
| [x] 060 | `POST` | `/api/session/{sessionID}/command` | `session.command` | Change | Renamed request field from `command` to `name`; `204` retained. |
| [x] 061 | `POST` | `/api/experimental/session/{sessionID}/skill` | `experimental.session.skill` | Experimental-only | Skill ID is now the `id` field; standalone activation remains experimental. |
| [x] 062 | `POST` | `/api/session/{sessionID}/synthetic` | `session.synthetic` | Keep | Durable synthetic admission and delivery controls retained. |
| [x] 063 | `POST` | `/api/session/{sessionID}/shell` | `session.shell` | Change | Caller ID is now the optimistic shell message ID; server derives its event ID. |
| [x] 064 | `POST` | `/api/session/{sessionID}/compact` | `session.compact` | Keep | Durable compaction admission and delivery controls retained. |
| [x] 065 | `POST` | `/api/experimental/session/{sessionID}/wait` | `experimental.session.wait` | Experimental-only | Race-free idle barrier retained outside the stable API. |
| [x] 066 | `POST` | `/api/session/{sessionID}/generate` | `session.generate` | Keep | Transient generation from session context retained. |
| [x] 067 | `POST` | `/api/session/{sessionID}/interrupt` | `session.interrupt` | Change | Renamed `continue` to `resume` across public and internal interruption APIs. |
| [x] 068 | `PUT` | `/api/session/{sessionID}/environment` | `session.environment` | Keep | Process-local environment replacement retained in the stable API. |
| [x] 069 | `POST` | `/api/session/{sessionID}/view` | `session.view` | Change | Idle watermark now uses the standard epoch-millisecond timestamp schema. |
## Group 6: Session history and recovery
**Endpoints:** 13
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 070 | `POST` | `/api/experimental/session/import` | `experimental.session.import` | Experimental-only | Existing projected transcript import contract retained outside the stable API. |
| [x] 071 | `GET` | `/api/experimental/session/{sessionID}/export` | `experimental.session.export` | Experimental-only | Existing projected transcript export contract retained outside the stable API. |
| [x] 072 | `POST` | `/api/session/{sessionID}/revert/stage` | `session.revert.stage` | Keep | Existing staged history and optional file restoration behavior retained. |
| [x] 073 | `DELETE` | `/api/session/{sessionID}/revert` | `session.revert.clear` | Change | Clearing staged revert now deletes the session revert resource. |
| [x] 074 | `POST` | `/api/session/{sessionID}/revert/commit` | `session.revert.commit` | Keep | Explicit staged-revert commit action retained. |
| [x] 075 | `GET` | `/api/session/{sessionID}/context` | `session.context` | Keep | Active model-context projection retained. |
| [x] 076 | `GET` | `/api/session/{sessionID}/diff` | `session.diff` | Keep | Turn-range structured diff contract retained. |
| [x] 077 | `GET` | `/api/experimental/session/{sessionID}/instructions/entries` | `experimental.session.instructions.entry.list` | Experimental-only | API-managed durable context entries retained outside the stable API. |
| [x] 078 | `PUT` | `/api/experimental/session/{sessionID}/instructions/entries/{key}` | `experimental.session.instructions.entry.put` | Experimental-only | API-managed durable context entries retained outside the stable API. |
| [x] 079 | `DELETE` | `/api/experimental/session/{sessionID}/instructions/entries/{key}` | `experimental.session.instructions.entry.remove` | Experimental-only | API-managed durable context entries retained outside the stable API. |
| [x] 080 | `GET` | `/api/experimental/session/{sessionID}/log` | `session.log` | Experimental-only | Retained outside the stable API commitment. |
| [x] 081 | `GET` | `/api/session/{sessionID}/message/{messageID}` | `session.message.get` | Change | Normalized specific-message operation ID. |
| [x] 082 | `GET` | `/api/session/{sessionID}/message` | `session.message.list` | Change | Normalized session-scoped message-list operation ID. |
## Group 7: Inbox, permissions, and forms
**Endpoints:** 19
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 083 | `GET` | `/api/session/{sessionID}/inbox` | `session.inbox.list` | Change | Inbox timestamps now use the standard nested `time.created` shape. |
| [x] 084 | `DELETE` | `/api/session/{sessionID}/inbox/{inboxID}` | `session.inbox.cancel` | Change | Cancellation is idempotent and returns `204` when the session exists. |
| [x] 085 | `PATCH` | `/api/session/{sessionID}/inbox/{inboxID}` | `session.inbox.update` | Change | Consolidated delivery mutation with `delivery: "steer" | "queue"`. |
| [x] 086 | — | — | — | Remove | Replaced by `session.inbox.update`. |
| [x] 087 | `GET` | `/api/form` | `form.list` | Change | Removed redundant `request` path and operation namespace. |
| [x] 088 | `GET` | `/api/session/{sessionID}/form` | `session.form.list` | Keep | Pending session form list retained with temporary MCP sentinel compatibility. |
| [x] 089 | `POST` | `/api/session/{sessionID}/form` | `session.form.create` | Keep | External form creation and temporary MCP sentinel ownership retained. |
| [x] 090 | `GET` | `/api/session/{sessionID}/form/{formID}` | `session.form.get` | Change | Form definition and lifecycle state are now returned together. |
| [x] 091 | — | — | — | Remove | State is included by `session.form.get`. |
| [x] 092 | `POST` | `/api/session/{sessionID}/form/{formID}/reply` | `session.form.reply` | Keep | One-shot validated form reply retained. |
| [x] 093 | `DELETE` | `/api/session/{sessionID}/form/{formID}` | `session.form.cancel` | Change | Form cancellation now deletes the pending form resource. |
| [x] 094 | `GET` | `/api/permission/request` | `permission.request.list` | Keep | Pending-request namespace retained alongside saved permissions. |
| [x] 095 | `GET` | `/api/permission/saved` | `permission.saved.list` | Change | Added persisted creation and update timestamps under `time`. |
| [x] 096 | `DELETE` | `/api/permission/saved/{id}` | `permission.saved.remove` | Keep | Idempotent saved-permission deletion retained. |
| [x] 097 | `POST` | `/api/session/{sessionID}/permission` | `session.permission.create` | Keep | Non-blocking permission evaluation and pending-request creation retained. |
| [x] 098 | `GET` | `/api/session/{sessionID}/permission` | `session.permission.list` | Keep | Pending session permission list retained. |
| [x] 099 | `GET` | `/api/session/{sessionID}/permission/{requestID}` | `session.permission.get` | Keep | Specific pending permission read with ownership validation retained. |
| [x] 100 | `POST` | `/api/session/{sessionID}/permission/{requestID}/reply` | `session.permission.reply` | Change | Renamed request field from `reply` to `decision`. |
| [x] 101 | — | — | — | Remove | Permission rules are updated through `session.update`. |
## Group 8: Filesystem, worktrees, and VCS
**Endpoints:** 12
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 102 | `GET` | `/api/fs/read/*` | `fs.read` | Keep | Relative wildcard file reads and raw byte responses retained. |
| [x] 103 | `GET` | `/api/fs/list` | `fs.list` | Keep | Existing path scope and minimal entry metadata retained. |
| [x] 104 | `GET` | `/api/fs/find` | `fs.find` | Keep | Existing ranked filesystem search retained. |
| [x] 105 | `GET` | `/api/worktree` | `worktree.list` | Keep | Reviewed separately by coworker. |
| [x] 106 | `POST` | `/api/worktree` | `worktree.create` | Keep | Reviewed separately by coworker. |
| [x] 107 | `DELETE` | `/api/worktree` | `worktree.remove` | Keep | Reviewed separately by coworker. |
| [x] 108 | `POST` | `/api/worktree/refresh` | `worktree.refresh` | Keep | Reviewed separately by coworker. |
| [x] 109 | `GET` | `/api/vcs` | `vcs.get` | Change | Preserved branch nesting and added selected VCS provider ID. |
| [x] 110 | `GET` | `/api/vcs/base` | `vcs.base` | Keep | Review-base inference and nullable unavailable state retained. |
| [x] 111 | `GET` | `/api/vcs/status` | `vcs.status` | Keep | Existing working-copy status shape retained for now. |
| [x] 112 | `GET` | `/api/vcs/branch` | `vcs.branch.list` | Change | Singular collection path and normalized operation ID. |
| [x] 113 | `GET` | `/api/vcs/diff` | `vcs.diff` | Keep | Existing working, branch, and committed comparison modes retained. |
## Group 9: PTYs, persistent terminals, and shells
**Endpoints:** 24
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 114 | `GET` | `/api/pty` | `pty.list` | Keep | PTY endpoints reviewed together and retained. |
| [x] 115 | `POST` | `/api/pty` | `pty.create` | Keep | PTY endpoints reviewed together and retained. |
| [x] 116 | `GET` | `/api/pty/{ptyID}` | `pty.get` | Keep | PTY endpoints reviewed together and retained. |
| [x] 117 | `PUT` | `/api/pty/{ptyID}` | `pty.update` | Keep | PTY endpoints reviewed together and retained. |
| [x] 118 | `DELETE` | `/api/pty/{ptyID}` | `pty.remove` | Keep | PTY endpoints reviewed together and retained. |
| [x] 119 | `POST` | `/api/pty/{ptyID}/connect-token` | `pty.connect.token` | Keep | PTY endpoints reviewed together and retained. |
| [x] 120 | `GET` | `/api/pty/{ptyID}/connect` | `pty.connect` | Keep | PTY endpoints reviewed together and retained. |
| [x] 121 | `GET` | `/api/experimental/session/{sessionID}/terminal/read` | `server.experimental.persistentPty.read` | Experimental-only | Retained outside the stable API commitment. |
| [x] 122 | `GET` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.list` | Experimental-only | Retained outside the stable API commitment. |
| [x] 123 | `POST` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.create` | Experimental-only | Retained outside the stable API commitment. |
| [x] 124 | `POST` | `/api/experimental/persistent-pty/shutdown` | `server.experimental.persistentPty.shutdown` | Experimental-only | Retained outside the stable API commitment. |
| [x] 125 | `POST` | `/api/experimental/persistent-pty/handoff` | `server.experimental.persistentPty.handoff` | Experimental-only | Retained outside the stable API commitment. |
| [x] 126 | `GET` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.get` | Experimental-only | Retained outside the stable API commitment. |
| [x] 127 | `PUT` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.update` | Experimental-only | Retained outside the stable API commitment. |
| [x] 128 | `DELETE` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.remove` | Experimental-only | Retained outside the stable API commitment. |
| [x] 129 | `GET` | `/api/experimental/persistent-pty/{ptyID}/snapshot` | `server.experimental.persistentPty.snapshot` | Experimental-only | Retained outside the stable API commitment. |
| [x] 130 | `POST` | `/api/experimental/persistent-pty/{ptyID}/connect-token` | `server.experimental.persistentPty.connectToken` | Experimental-only | Retained outside the stable API commitment. |
| [x] 131 | `GET` | `/api/experimental/persistent-pty/{ptyID}/connect` | `persistentPty.connect` | Experimental-only | Retained outside the stable API commitment. |
| [x] 132 | `GET` | `/api/shell` | `shell.list` | Change | Stable shell inventory retained; numeric timestamps documented as epoch milliseconds. |
| [x] 133 | `POST` | `/api/shell` | `shell.create` | Change | Timeout is optional and defaults to zero; caller metadata retained. |
| [x] 134 | `GET` | `/api/shell/{id}` | `shell.get` | Keep | Specific running or retained shell read retained. |
| [x] 135 | `DELETE` | `/api/shell/{id}` | `shell.remove` | Change | Shell deletion is idempotent and returns `204` when already absent. |
| [x] 136 | — | — | — | Remove | Timeout mutation remains an internal Core shell operation. |
| [x] 137 | `GET` | `/api/shell/{id}/output` | `shell.output` | Keep | Existing byte-cursor text output paging retained. |
## Group 10: Events, RPC, and experimental operations
**Endpoints:** 6
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 138 | `POST` | `/api/experimental/generate` | `experimental.generate.text` | Experimental-only | Stateless generation retained alongside session generation. |
| [x] 139 | `POST` | `/api/rpc/{rpcID}/{method}` | `rpc.call` | Keep | Generic typed-error plugin RPC transport retained. |
| [x] 140 | `GET` | `/api/event` | `event.subscribe` | Keep | Unified native and dynamic plugin event stream retained. |
| [x] 141 | `GET` | `/api/debug/location` | `debug.location.list` | Keep | Loaded-location debug inventory retained. |
| [x] 142 | `DELETE` | `/api/debug/location` | `debug.location.evict` | Keep | Idempotent loaded-location eviction retained. |
| [x] 143 | `GET` | `/api/experimental/migration/v1` | `experimental.migration.v1.status` | Experimental-only | Retained outside the stable API commitment. |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

+3278 -2406
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -2,7 +2,7 @@
exact = true
# Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@opencode/sdk", "@opencode-ai/pty", "@opencode-ai/pty-darwin-arm64", "@opencode-ai/pty-darwin-x64", "@opencode-ai/pty-linux-arm64-gnu", "@opencode-ai/pty-linux-arm64-musl", "@opencode-ai/pty-linux-x64-gnu", "@opencode-ai/pty-linux-x64-musl", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron", "electron-builder", "electron-publish", "blume", "mermaid"]
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opencode-ai/sdk", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
[test]
root = "./do-not-run-tests-from-root"
-37
View File
@@ -1,37 +0,0 @@
// BEGIN: baseline — first-line change
type ReviewState = "pending" | "ready" | "failed"
type ReviewProps = { title: string; count: number; state: ReviewState }
export const heading = "Review the old checkout experience"
export const count = 8
export const obsolete = "delete this isolated row"
export const anchor = "unchanged between deletion and addition"
// Contiguous span versus several separated small spans.
export const contiguous = "Keep the original checkout message readable"
export const scattered = "alpha: old; beta: cold; gamma: slow"
export const oneCharacter = "item-7"
export const atStart = "before middle remains ending remains"
export const atMiddle = "start remains before ending remains"
export const atEnd = "start remains middle remains before"
export const punctuation = { label: "Ready", enabled: true }
export const spaces = "one two three"
export const indent = "indent-only change"
export const trailing = "trailing-space-only change"
// Syntax competition: comment, keyword, type, boolean, string, number.
export function ReviewCard(props: ReviewProps) {
const status: ReviewState = "pending"
const enabled = false
const retries = 3
// Keep this quiet explanation readable on a tinted row.
return <section aria-label="Old review" data-state={status}>
<h2>{props.title}</h2>
<span className="text-muted">{props.count} old items</span>
<button disabled={!enabled}>Continue checkout</button>
</section>
}
export const longLine = "Start unchanged | The old checkout flow waits for manual confirmation before showing the receipt | Middle unchanged with punctuation: brackets [one, two], braces {three}, quotes 'four', slash /five/ | The old final instruction asks the reviewer to close the window | End unchanged"
// END: baseline — last-line change
-66
View File
@@ -1,66 +0,0 @@
// BEGIN baseline
export const first = "old"
// quiet context A01
// quiet context A02
// quiet context A03
// quiet context A04
// quiet context A05
// quiet context A06
// quiet context A07
// quiet context A08
// quiet context A09
// quiet context A10
// quiet context A11
// quiet context A12
// quiet context A13
// quiet context A14
// quiet context A15
// quiet context A16
// quiet context A17
// quiet context A18
// quiet context A19
// quiet context A20
export const second = "old"
// quiet context B01
// quiet context B02
// quiet context B03
// quiet context B04
// quiet context B05
// quiet context B06
// quiet context B07
// quiet context B08
// quiet context B09
// quiet context B10
// quiet context B11
// quiet context B12
// quiet context B13
// quiet context B14
// quiet context B15
// quiet context B16
// quiet context B17
// quiet context B18
// quiet context B19
// quiet context B20
export const third = "old"
// quiet context C01
// quiet context C02
// quiet context C03
// quiet context C04
// quiet context C05
// quiet context C06
// quiet context C07
// quiet context C08
// quiet context C09
// quiet context C10
// quiet context C11
// quiet context C12
// quiet context C13
// quiet context C14
// quiet context C15
// quiet context C16
// quiet context C17
// quiet context C18
// quiet context C19
// quiet context C20
export const fourth = "old"
// END baseline
-21
View File
@@ -1,21 +0,0 @@
# Before: plain-text review
Unchanged prose should remain quiet, not compete with changed rows.
Replace one contiguous phrase: the original checkout experience stays here.
Several little edits: red apple, cold tea, slow train.
One character: ticket 7.
before — the middle and the end remain unchanged.
The start remains — before — the end remains.
The start and the middle remain — before
Punctuation only: hello, world!
Whitespace only: one two three
Trailing whitespace only.
Delete this sentence without a replacement.
This unchanged sentence separates a deletion from an addition.
Long prose: The original checkout flow asks the reader to review the old confirmation message before proceeding through the receipt screen, while the unchanged middle of this deliberately long paragraph checks whether horizontal scrolling or line wrapping keeps small inline edits visible at a narrow viewport; the final old phrase appears near the far right edge.
**Syntax-like Markdown** competes with `inline code`, [links](https://example.com/old), and _emphasis_.
Final sentence before.
-63
View File
@@ -1,63 +0,0 @@
// Large file: unchanged prefix and suffix surround a dense block.
export const records = [
{ id: "row-001", state: "pending", count: 1 },
{ id: "row-002", state: "pending", count: 2 },
{ id: "row-003", state: "pending", count: 3 },
{ id: "row-004", state: "pending", count: 4 },
{ id: "row-005", state: "pending", count: 5 },
{ id: "row-006", state: "pending", count: 6 },
{ id: "row-007", state: "pending", count: 7 },
{ id: "row-008", state: "pending", count: 8 },
{ id: "row-009", state: "pending", count: 9 },
{ id: "row-010", state: "pending", count: 10 },
{ id: "row-011", state: "pending", count: 11 },
{ id: "row-012", state: "pending", count: 12 },
{ id: "row-013", state: "pending", count: 13 },
{ id: "row-014", state: "pending", count: 14 },
{ id: "row-015", state: "pending", count: 15 },
{ id: "row-016", state: "pending", count: 16 },
{ id: "row-017", state: "pending", count: 17 },
{ id: "row-018", state: "pending", count: 18 },
{ id: "row-019", state: "pending", count: 19 },
{ id: "row-020", state: "pending", count: 20 },
{ id: "row-021", state: "pending", count: 21 },
{ id: "row-022", state: "pending", count: 22 },
{ id: "row-023", state: "pending", count: 23 },
{ id: "row-024", state: "pending", count: 24 },
{ id: "row-025", state: "pending", count: 25 },
{ id: "row-026", state: "pending", count: 26 },
{ id: "row-027", state: "pending", count: 27 },
{ id: "row-028", state: "pending", count: 28 },
{ id: "row-029", state: "pending", count: 29 },
{ id: "row-030", state: "pending", count: 30 },
{ id: "row-031", state: "pending", count: 31 },
{ id: "row-032", state: "pending", count: 32 },
{ id: "row-033", state: "pending", count: 33 },
{ id: "row-034", state: "pending", count: 34 },
{ id: "row-035", state: "pending", count: 35 },
{ id: "row-036", state: "pending", count: 36 },
{ id: "row-037", state: "pending", count: 37 },
{ id: "row-038", state: "pending", count: 38 },
{ id: "row-039", state: "pending", count: 39 },
{ id: "row-040", state: "pending", count: 40 },
{ id: "row-041", state: "pending", count: 41 },
{ id: "row-042", state: "pending", count: 42 },
{ id: "row-043", state: "pending", count: 43 },
{ id: "row-044", state: "pending", count: 44 },
{ id: "row-045", state: "pending", count: 45 },
{ id: "row-046", state: "pending", count: 46 },
{ id: "row-047", state: "pending", count: 47 },
{ id: "row-048", state: "pending", count: 48 },
{ id: "row-049", state: "pending", count: 49 },
{ id: "row-050", state: "pending", count: 50 },
{ id: "row-051", state: "pending", count: 51 },
{ id: "row-052", state: "pending", count: 52 },
{ id: "row-053", state: "pending", count: 53 },
{ id: "row-054", state: "pending", count: 54 },
{ id: "row-055", state: "pending", count: 55 },
{ id: "row-056", state: "pending", count: 56 },
{ id: "row-057", state: "pending", count: 57 },
{ id: "row-058", state: "pending", count: 58 },
{ id: "row-059", state: "pending", count: 59 },
{ id: "row-060", state: "pending", count: 60 },
]
-4
View File
@@ -1,4 +0,0 @@
This entire tracked file will be deleted after the baseline commit.
Only deletion tint should be present.
No syntax colors should compete with this text.
Last deleted row.
-41
View File
@@ -1,41 +0,0 @@
/* Baseline stylesheet: colors, units, selectors, at-rules. */
:root {
--brand: #3b5cf6;
--radius: 6px;
--gap: 8px;
}
.card {
display: flex;
gap: var(--gap);
padding: 12px 16px;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--radius);
background: #ffffff;
color: #161616;
}
.card:hover {
background: #fafafa;
}
.card[data-state="error"] {
color: #b82d35;
border-color: #f2bbb7;
}
@media (max-width: 768px) {
.card {
flex-direction: column;
padding: 8px;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
-21
View File
@@ -1,21 +0,0 @@
{
"name": "diff-color-fixture",
"version": "1.0.0",
"private": true,
"settings": {
"theme": "light",
"fontSize": 13,
"lineHeight": 20,
"wrap": false,
"tabs": ["review", "files", "terminal"]
},
"features": {
"inlineHighlights": true,
"foldUnchanged": true,
"splitView": false
},
"limits": {
"maxFiles": 100,
"maxLineLength": 1000
}
}
-38
View File
@@ -1,38 +0,0 @@
"""Baseline Python service: decorators, f-strings, indentation, comments."""
import asyncio
from dataclasses import dataclass
@dataclass
class Review:
id: str
title: str
approved: bool = False
class ReviewService:
def __init__(self, timeout: float = 2.0) -> None:
self.timeout = timeout
self.cache: dict[str, Review] = {}
async def load(self, review_id: str) -> Review | None:
# Return cached reviews before touching the network.
if review_id in self.cache:
return self.cache[review_id]
await asyncio.sleep(self.timeout)
return None
def summary(self, review: Review) -> str:
status = "approved" if review.approved else "pending"
return f"Review {review.id}: {review.title} ({status})"
def main() -> None:
service = ReviewService()
review = Review(id="r-1", title="Checkout")
print(service.summary(review))
if __name__ == "__main__":
main()
-19
View File
@@ -1,19 +0,0 @@
// Moved and reordered blocks: the same code appears as deletion here and addition elsewhere.
export function first() {
return "first"
}
export function second() {
const values = [1, 2, 3]
return values.map((value) => value * 2)
}
export function third() {
return "third"
}
export function fourth() {
return "fourth"
}
export const order = ["first", "second", "third", "fourth"]
-21
View File
@@ -1,21 +0,0 @@
#!/usr/bin/env bash
# Baseline shell script: variables, quoting, conditionals.
set -euo pipefail
ENVIRONMENT="${1:-staging}"
REGION="us-east-1"
RETRIES=3
log() {
echo "[deploy] $*"
}
if [[ "$ENVIRONMENT" == "production" ]]; then
log "Deploying to production in $REGION"
else
log "Deploying to $ENVIRONMENT"
fi
for attempt in $(seq 1 "$RETRIES"); do
log "Attempt $attempt of $RETRIES"
done
-19
View File
@@ -1,19 +0,0 @@
name: review
on:
push:
branches: [v2]
pull_request:
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Install
run: bun install
- name: Check
run: bun run check
env:
CI: true
LOG_LEVEL: info
-12
View File
@@ -1,12 +0,0 @@
# Unicode and mixed scripts
Emoji: ✅ ready, ⚠️ warning, ❌ failed.
CJK: 代码审查 完成。
Japanese: レビューを開始します。
Korean: 변경 사항을 검토합니다.
Arabic (RTL): مراجعة التغييرات
Hebrew (RTL): סקירת שינויים
Accents: café, naïve, résumé.
Math: a ≤ b, x ≠ y, ∑ values.
Box: ┌─┐ │ │ └─┘
Combining: e\u0301 vs é
-18
View File
@@ -1,18 +0,0 @@
package fixture
import "fmt"
// Tab-indented Go: tab-width rendering and tab-only changes.
type Review struct {
ID string
Title string
Approved bool
}
func (r Review) Summary() string {
status := "pending"
if r.Approved {
status = "approved"
}
return fmt.Sprintf("%s: %s (%s)", r.ID, r.Title, status)
}
Whitespace-only changes.
@@ -1,3 +0,0 @@
This tracked file keeps existing but loses all content.
Every row should render as a deletion.
The file itself is not deleted.
-2
View File
@@ -1,2 +0,0 @@
First line
Last line without trailing newline
-14
View File
@@ -1,14 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Review</title>
</head>
<body class="light">
<main id="app" data-state="idle">
<h1>Old heading</h1>
<button type="button" disabled>Continue</button>
<!-- baseline comment -->
</main>
</body>
</html>
-6
View File
@@ -1,6 +0,0 @@
SELECT id, title, status
FROM reviews
WHERE status = 'pending'
AND created_at > NOW() - INTERVAL '7 days'
ORDER BY created_at DESC
LIMIT 50;
-15
View File
@@ -1,15 +0,0 @@
# Temporary diff color review
Local-only visual fixture. Not imported by product code. Do not format these files.
After the baseline commit, leave all example edits uncommitted.
- `01-inline.tsx`: adjacent rows, inline spans, one character, syntax, whitespace, long line.
- `02-folded.ts`: isolated hunks and multiple collapsed unchanged sections.
- `03-prose.md`: plain prose, punctuation, spaces, file boundaries, long prose.
- `04-many-rows.ts`: larger dense changed region.
- `05-deleted.txt` / `06-added.txt`: whole-file deletion / addition.
Open this worktree in OpenCode, open Review, select Uncommitted changes, and choose a file.
Compare the default OpenCode theme in Light and Dark with Unified and Split layouts.
Use a 1440 × 1000 viewport for comparisons and 800 × 1000 for the narrow case.
-147
View File
@@ -1,147 +0,0 @@
# File diff color tokens and CSS variables
Inventory for this worktree's V2 web/desktop file viewer. Source inspection only; no colors or behavior changed. Includes diff-specific colors, syntax colors, and the selection/search/comment colors used inside the viewer—not every unrelated global app variable. Unified and split use the same color system.
## Main controls and their wiring
| Visual role | Renderer variable | OpenCode source |
|---|---|---|
| Neutral file background | `--diffs-bg` | `--opencode-diffs-bg`, falling back to `--color-background-stronger` (alias of `--background-stronger`) |
| Default text | `--fg` / `--diffs-fg` | Registered theme foreground: `--text-base`; syntax spans override it |
| Addition seed / gutter bar | `--diffs-addition-color` → `--diffs-addition-base` | `--syntax-diff-add` |
| Deletion seed / gutter bar | `--diffs-deletion-color` → `--diffs-deletion-base` | `--syntax-diff-delete` |
| Modified seed | `--diffs-modified-color` → `--diffs-modified-base` | `--syntax-diff-unknown` |
| Added row | `--diffs-bg-addition`, `--diffs-bg-addition-override` | Mix of neutral background and addition seed |
| Deleted row | `--diffs-bg-deletion`, `--diffs-bg-deletion-override` | Mix of neutral background and deletion seed |
| Added inline span | `--diffs-bg-addition-emphasis`, `--diffs-bg-addition-emphasis-override` | Alpha of addition seed |
| Deleted inline span | `--diffs-bg-deletion-emphasis`, `--diffs-bg-deletion-emphasis-override` | Alpha of deletion seed; softened on selected deletion rows |
| Folded “unmodified lines” row | `--diffs-bg-separator`, `--diffs-bg-separator-override` | Mix of neutral background and `--diffs-mixer` |
| Ordinary line numbers / fold text | `--diffs-fg-number`, `--diffs-fg-number-override` | Foreground/background mix |
| Selection background | `--diffs-selection-base` → `--diffs-bg-selection` | `--v2-background-bg-accent` |
| Selected line-number text | `--diffs-selection-number-fg` | `--v2-text-text-accent` |
| Comment annotation background | `--diffs-comment-bg` | Alpha of `--v2-background-bg-accent` |
| Search match / current match | CSS highlight backgrounds | Alpha of `--surface-warning-base` / `--surface-warning-strong` |
**Important:** `--surface-diff-add-*`, `--surface-diff-delete-*`, and `--surface-diff-hidden-*` exist in the global theme, but are not the direct row/inline/fold controls in the current Pierre rendering path. The older/custom separator CSS in `components/file.css` does use `--surface-diff-hidden-base` and `--surface-diff-hidden-strong`.
## OpenCode diff-specific theme tokens — complete families
Each name below is a CSS custom property. Theme JSON override keys omit the leading `--`. Tailwind's `--color-…` aliases are defined in `packages/ui/src/styles/tailwind/colors.css` (for example `--color-surface-diff-add-base` → `--surface-diff-add-base`); these are aliases, not separate color decisions.
### Surfaces
```text
--surface-diff-unchanged-base
--surface-diff-skip-base
--surface-diff-hidden-base
--surface-diff-hidden-weak
--surface-diff-hidden-weaker
--surface-diff-hidden-strong
--surface-diff-hidden-stronger
--surface-diff-add-base
--surface-diff-add-weak
--surface-diff-add-weaker
--surface-diff-add-strong
--surface-diff-add-stronger
--surface-diff-delete-base
--surface-diff-delete-weak
--surface-diff-delete-weaker
--surface-diff-delete-strong
--surface-diff-delete-stronger
```
### Text, icons, and highlighter diff seeds
```text
--text-diff-add-base
--text-diff-add-strong
--text-diff-delete-base
--text-diff-delete-strong
--icon-diff-add-base
--icon-diff-add-hover
--icon-diff-add-active
--icon-diff-delete-base
--icon-diff-delete-hover
--icon-diff-modified-base
--syntax-diff-add
--syntax-diff-delete
--syntax-diff-unknown
```
The built-in OpenCode theme maps `--syntax-diff-add` and `--text-diff-add-base` to `--v2-state-fg-success`, and their delete counterparts to `--v2-state-fg-danger`. Optional palette seeds `diffAdd` and `diffDelete` also exist for theme resolution; they are theme inputs, not CSS variable names, and built-in overrides can supersede generated results.
## Syntax foreground tokens
```text
--syntax-comment
--syntax-regexp
--syntax-string
--syntax-keyword
--syntax-primitive
--syntax-operator
--syntax-variable
--syntax-property
--syntax-type
--syntax-constant
--syntax-punctuation
--syntax-object
--syntax-success
--syntax-warning
--syntax-critical
--syntax-info
--syntax-unknown
```
`--syntax-unknown` is referenced by the registered highlighter, but no definition was found in the current UI theme sources: treat it as an unresolved reference, not an existing resolved theme token. `--syntax-success` is available globally, although not directly used by that registered theme's current token-color rules.
Built-in syntax overrides additionally reference `--v2-text-text-muted`, `--v2-pink-800`, `--v2-green-800`, `--v2-orange-800`, `--v2-purple-800`, and `--v2-red-800` (with separate dark/light resolutions). Other syntax tokens use the ordinary text tokens or theme-resolved colors.
## Pierre renderer color variables — complete relevant inventory
These come from the installed `@pierre/diffs` **1.5.1** stylesheet plus OpenCode's injected CSS. `*-override` variables are hooks; other variables include derived outputs and internal implementation details, not independent OpenCode theme tokens. Some optional hooks are unset by default.
| Group | Variables |
|---|---|
| Base | `--diffs-bg`, `--diffs-fg`, `--diffs-mixer`, `--opencode-diffs-bg`, `--bg`, `--fg` |
| Theme foreground/background variants | `--diffs-light`, `--diffs-dark`, `--diffs-light-bg`, `--diffs-dark-bg` |
| Neutral backgrounds | `--diffs-bg-buffer`, `--diffs-bg-buffer-override`, `--diffs-bg-context`, `--diffs-bg-context-override`, `--diffs-bg-context-gutter`, `--diffs-bg-context-gutter-override`, `--diffs-bg-separator`, `--diffs-bg-separator-override` |
| Number / conflict-marker foreground | `--diffs-fg-number`, `--diffs-fg-number-override`, `--diffs-fg-number-addition-override`, `--diffs-fg-number-deletion-override`, `--diffs-fg-conflict-marker`, `--diffs-fg-conflict-marker-override` |
| Addition seed | `--diffs-addition-base`, `--diffs-addition-color`, `--diffs-addition-color-override`, `--diffs-light-addition-color`, `--diffs-dark-addition-color` |
| Deletion seed | `--diffs-deletion-base`, `--diffs-deletion-color`, `--diffs-deletion-color-override`, `--diffs-light-deletion-color`, `--diffs-dark-deletion-color` |
| Modified seed | `--diffs-modified-base`, `--diffs-modified-color`, `--diffs-modified-color-override`, `--diffs-light-modified-color`, `--diffs-dark-modified-color` |
| Renderer fallback colors | `--diffs-added-light`, `--diffs-added-dark`, `--diffs-deleted-light`, `--diffs-deleted-dark`, `--diffs-modified-light`, `--diffs-modified-dark`, `--diffs-warning-light`, `--diffs-warning-dark` |
| Added row/gutter/inline | `--diffs-bg-addition`, `--diffs-bg-addition-override`, `--diffs-bg-addition-number-override`, `--diffs-bg-addition-emphasis`, `--diffs-bg-addition-emphasis-override` |
| Deleted row/gutter/inline | `--diffs-bg-deletion`, `--diffs-bg-deletion-override`, `--diffs-bg-deletion-number-override`, `--diffs-bg-deletion-emphasis`, `--diffs-bg-deletion-emphasis-override` |
| Selection | `--diffs-selection-base`, `--diffs-selection-number-fg`, `--diffs-bg-selection`, `--diffs-bg-selection-override`, `--diffs-bg-selection-number`, `--diffs-bg-selection-number-override`, `--diffs-bg-selection-text` |
| Hover | `--diffs-bg-hover-override`, `--diffs-hover-mix-target` |
| Comments / decoration | `--diffs-comment-bg`, `--diffs-annotation-bg`, `--diffs-decoration-bg`, `--diffs-decoration-bar-color` |
| Internal background pipeline | `--diffs-computed-decoration-bg`, `--diffs-computed-diff-line-bg`, `--diffs-computed-selected-line-bg`, `--diffs-computed-editor-active-line-bg`, `--diffs-computed-hovered-line-bg`, `--diffs-line-bg` |
| Internal mix targets | `--diffs-diff-line-mix-target`, `--diffs-selection-mix-target`, `--diffs-selection-emphasis-mix-target` |
| Per-token light/dark colors | `--diffs-token-light`, `--diffs-token-dark`, `--diffs-token-light-bg`, `--diffs-token-dark-bg` |
| Conflict backgrounds (library support; not exercised by the fixture) | `--conflict-bg-current-header-override`, `--conflict-bg-current-number-override`, `--conflict-bg-current-override`, `--conflict-bg-incoming-header-override`, `--conflict-bg-incoming-number-override`, `--conflict-bg-incoming-override` |
Related mix controls are **percentages, not colors**: `--mix-light`, `--mix-dark`, `--mix-deco-light`, `--mix-deco-dark`, `--mix-selection-light`, `--mix-selection-dark`, and `--diffs-editor-active-line-source-mix`.
## Other color tokens used inside the file viewer
- Text: `--text-base`, `--text-weak`, `--text-strong`.
- Background fallback: `--background-stronger`, `--color-background-stronger`; comment actions also use `--background-base`.
- Selection: `--v2-background-bg-accent`, `--v2-text-text-accent`.
- Search: `--surface-warning-base`, `--surface-warning-strong`.
- Custom fold UI: `--surface-diff-hidden-base`, `--surface-diff-hidden-strong`, `--icon-strong-base`; `--text-mix-blend-mode` affects compositing but is not a color.
- Comment controls: `--icon-interactive-base`, `--white`, `--surface-raised-stronger-non-alpha`, `--surface-base`, `--surface-raised-base-hover`, `--border-base`, `--text-weak`, `--text-strong`, `--background-base`.
- Comment shadows (composite shadow tokens, not single colors): `--shadow-xs`, `--shadow-xs-border-focus`, `--shadow-xxs-border`, `--shadow-xs-border-select`.
Typography, sizing, gaps, gutter widths, font weight/style, and text-decoration variables are deliberately excluded from this color inventory.
## Source locations
- `packages/session-ui/src/pierre/index.ts`: viewer color overrides, selection, search, comment tint.
- `packages/ui/src/context/marked-theme.tsx`: registered foreground/background, diff seeds, syntax mappings.
- `packages/ui/src/theme/resolve.ts`: global diff/syntax token generation.
- `packages/ui/src/theme/themes/oc-2.json`: built-in light/dark mappings.
- `packages/ui/src/styles/theme.css` and `styles/tailwind/colors.css`: defaults and aliases.
- `packages/session-ui/src/components/file.css`: custom fold-separator styling.
- `packages/session-ui/src/components/line-comment-styles.ts`: comment controls.
- Installed `@pierre/diffs/dist/style.js`: renderer variables and derived backgrounds.
- Installed `@pierre/diffs/dist/utils/getHighlighterThemeStyles.js`: highlighter-to-renderer seed bridge.
-36
View File
@@ -1,36 +0,0 @@
# Diff color review (temporary)
Review-only material for the diff color PR. This whole commit is dropped before merge: `diff-color-fixture/`,
`diff-color-review-artifacts/`, `packages/session-ui/src/pierre/diff-color-tuning.ts`, and its import in
`packages/session-ui/src/pierre/index.ts`.
## See the example diffs
The fixture baselines are committed; their edits ship as a patch so they show up as uncommitted Git changes.
```sh
git apply diff-color-review-artifacts/fixture-edits.patch
```
Open a session in this worktree, open **Review → Git changes**, and pick a file in `diff-color-fixture/`.
Undo with `git checkout -- diff-color-fixture && git clean -fd diff-color-fixture`.
## Tune colors yourself
With the desktop dev app running from this worktree (`bun run dev:desktop`):
```sh
bun run diff-color-review-artifacts/tuner/server.ts
```
Open http://127.0.0.1:4455 (any browser, or the OpenCode browser pane).
- Every change saves to `diff-color-tuning.ts` (hot-reloaded) and is pushed live into open diffs through the dev
app's debug port (9222; override with `TUNER_CDP`).
- **Override set**: `Reference` is the committed, read-only set used for this PR (it matches the branch colors, so it
previews as no change). Editing it saves a copy; use **New** for a blank set. Your sets stay local and untracked.
- “Current” leaves the branch colors untouched. Slots can be an exact color or a v2 token, with opacity.
- **Copy CSS** / **Paste CSS** share a set with someone else; Undo/Redo and per-category toggles are in the toolbar.
`REPORT.md` and the screenshots document the colors before this change; `COLOR-TOKENS.md` lists the diff view's
color tokens and Pierre variables.
-69
View File
@@ -1,69 +0,0 @@
# OpenCode V2 diff color review — temporary fixture
[File diff color token / CSS variable inventory](COLOR-TOKENS.md)
## Location and refs
- Worktree: `/Users/usrnk1/.local/share/opencode/worktree/012780/diff-color-review`
- Branch: `diff-color-review` (local only).
- Fetched `origin/v2`: `7e42a897bc6dbbba580392d779e47137038bcafb`.
- Local baseline / HEAD: `5e14ede13c260b3b1bd2e16c5bb25722d12f366b`.
- Fixture: `diff-color-fixture/01-inline.tsx`, `02-folded.ts`, `03-prose.md`, `04-many-rows.ts`, `05-deleted.txt` (deleted), `06-added.txt` (untracked addition).
- `diff-color-fixture/README.md` is tracked and unchanged.
- Only fixtures changed. No renderer, token, behavior, formatter, push, PR, or deployment changes.
## Actual UI and how to open
The captures use the real source-backed OpenCode web app from this worktree, connected to the existing V2 background server (2.0.21). No mocked data or copied renderer. The app dev server remains available at `http://127.0.0.1:4444` and was started with `VITE_OPENCODE_SERVER_PORT=49374 bun run dev -- --port 4444` from `packages/app`. Existing app/server processes were not restarted.
Open:
`http://127.0.0.1:4444/server/aHR0cDovLzEyNy4wLjAuMTo0OTM3NA/session/ses_f085f0176ffeiNj9364c2CnHqm`
Alternatively open this session in OpenCode Desktop; its location is now this worktree. Toggle Review, choose **Git changes**, and select a fixture file. This UI calls the working-tree source “Git changes,” not “Uncommitted changes.” Use the file-tree toggle if the list is hidden. Unified/Split controls are in the review toolbar.
Captures: 1440 × 1000 CSS pixels, default OpenCode (`oc-2`) theme, system light/dark media preference. Chat pane was resized to its minimum and the file tree hidden to give the diff about 964 pixels. Wrapping was enabled by the production viewer. Close-ups below are unchanged crops of those screenshots, not re-rendered mockups.
## Representative screenshots
- [Dark inline close-up](dark-inline-closeup.png) / [Light inline close-up](light-inline-closeup.png)
- [Dark split inline](dark-split-inline.png) / [Light split inline](light-split-inline.png)
- [Dark split folded: all three bars](dark-split-folded.png) / [Light split folded](light-split-folded.png)
- [Dark unified folded](dark-unified-folded.png) / [Light unified folded](light-unified-folded.png)
- [Dark syntax and wrapped long line](dark-split-syntax-long.png) / [Light syntax and wrapped long line](light-split-syntax-long.png)
- [Dark Markdown](dark-split-prose.png) / [Light Markdown](light-split-prose.png)
Additional unified captures are in this same directory.
## Case notes
| Case | Unchanged sections | Red/green scan | Inline emphasis | Text readability |
|---|---|---|---|---|
| Single additions/deletions/replacements, adjacent rows (`01`, `03`) | Neutral background recedes; unchanged syntax can still draw the eye | Light is clearer; dark relies more on gutter bars and line numbers | Distinct but restrained | TS strings stay green even on red deletion rows, competing with change meaning |
| Contiguous versus separated edits (`01` lines 11–12; long line) | Common words inside an inline span do **not** recede | Row direction is still clear from gutter and tint | Current production `word-line` rendering joins separated edits into one broad span; multiple independent highlight islands were not available in this state | Broad wrapped emphasis adds visual weight without improving precision |
| One-character, start/middle/end edits (`01` lines 13–16; `03`) | Surrounding text remains readable | Large words are easy to find; one-character changes need deliberate attention | One-character patch is visible but easy to miss at normal scan speed | No observed loss of string readability |
| Punctuation and spaces/indent/trailing spaces (`01` lines 17–20; `03`) | Quiet surroundings | Row markers expose that something changed | Small semicolon/space blocks are visible on close inspection; blank blocks give little explanation without visible whitespace glyphs | Readable; punctuation has less salience than colored keywords |
| Isolated hunks and collapsed sections (`02`) | Neutral context recedes; three bars visible simultaneously in split | Distinct isolated rows, but dark tints are weak | Small changed-word blocks don't dominate rows | Fold-label text is unusually faint, especially dark; context comments are clearer than the fold labels |
| TS/TSX types, keywords, comments and JSX (`01`) | Syntax in unchanged rows remains fairly prominent | Pink keywords/green strings compete with the red/green layer | Emphasis is generally subordinate; the long merged span is the exception | Comments/strings/types readable overall; muted JSX words like “old,” “new,” and button text are weak on tinted/highlighted backgrounds in dark mode |
| Markdown and wrapped prose (`03`) | Plain prose is visually quieter than code | Light tints are clearer than dark | Long spans emphasize unchanged middle words as well as changes | Plain prose remains readable but looks muted; bold/heading syntax draws attention more strongly than small edit spans |
| Long/wrapped lines (`01`, `03`) | Common middle text is swallowed by merged emphasis | Tinted multi-line blocks are apparent | Broad highlights repeat across wraps and visually dominate small changes | Wrapping works in unified and split; side-by-side requires more vertical scanning |
| File beginning/end (`01`, `02`, `03`) | Neutral surrounding rows recede | Changes shown at first/last lines | Same inline behavior as middle edits | Readable |
| Dense larger file (`04`: 63 lines, 40 replacements / 80 changed rows) | **Not visually checked** | Not checked | Not checked | Fixture ready for review |
| Added/deleted whole files (`05`, `06`) | Viewer lists both with D/A badges | **File contents not visually checked** | Not checked | Fixture ready for review |
| Narrow viewport (planned 800 × 1000) | **Not checked** | Not checked | Not checked | Stopped expanding capture scope at user request |
## Specific problems to carry into a later design pass
1. Dark row backgrounds have weak separation from neutral context; syntax hue is often more salient than change direction.
2. Green string syntax appears on deleted rows too, weakening red/green semantic scanning. Pink keywords similarly attract attention independently of change status.
3. Collapsed-section labels recede too far: their subdued text is harder to read than surrounding context comments.
4. One-character, punctuation, and whitespace highlights are easy to miss without focused inspection. Their issue is subtlety/size, not overpowering brightness.
5. Muted JSX text on dark inline backgrounds is less readable than the surrounding syntax. Broad inline spans across wrapped lines add disproportionate visual mass.
The merged-span behavior is a renderer observation, not a proposed color fix. No final values selected, no fixes implemented, and no contrast-ratio compliance claim made.
## GitHub Desktop comparison boundary
The referenced discussion/image was not included in this session. A precise comparison to that direction is therefore **not verified**. As a provisional hierarchy comparison only: quiet context → recognizable changed row → localized stronger inline emphasis is the useful target. OpenCode already keeps ordinary inline backgrounds subordinate to rows, but dark row separation, syntax competition, faint fold controls, and merged broad spans weaken that hierarchy. This is not a claim that the specific GitHub Desktop reference was inspected.
Scope was intentionally stopped after the user's request to do less. No full lint/typecheck was run: these are standalone visual examples with deliberate whitespace/formatting changes, not product code.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 289 KiB

@@ -1,601 +0,0 @@
diff --git a/diff-color-fixture/01-inline.tsx b/diff-color-fixture/01-inline.tsx
index 1d16049d61..5074a3bc83 100644
--- a/diff-color-fixture/01-inline.tsx
+++ b/diff-color-fixture/01-inline.tsx
@@ -1,37 +1,37 @@
-// BEGIN: baseline — first-line change
+// BEGIN: edited — first-line change
type ReviewState = "pending" | "ready" | "failed"
type ReviewProps = { title: string; count: number; state: ReviewState }
-export const heading = "Review the old checkout experience"
-export const count = 8
-export const obsolete = "delete this isolated row"
+export const heading = "Review the new checkout experience"
+export const count = 9
export const anchor = "unchanged between deletion and addition"
+export const added = "add this isolated row"
// Contiguous span versus several separated small spans.
-export const contiguous = "Keep the original checkout message readable"
-export const scattered = "alpha: old; beta: cold; gamma: slow"
-export const oneCharacter = "item-7"
-export const atStart = "before middle remains ending remains"
-export const atMiddle = "start remains before ending remains"
-export const atEnd = "start remains middle remains before"
-export const punctuation = { label: "Ready", enabled: true }
-export const spaces = "one two three"
-export const indent = "indent-only change"
-export const trailing = "trailing-space-only change"
+export const contiguous = "Keep the redesigned payment summary readable"
+export const scattered = "alpha: new; beta: warm; gamma: fast"
+export const oneCharacter = "item-8"
+export const atStart = "after middle remains ending remains"
+export const atMiddle = "start remains after ending remains"
+export const atEnd = "start remains middle remains after"
+export const punctuation = { label: "Ready", enabled: true };
+export const spaces = "one two three"
+ export const indent = "indent-only change"
+export const trailing = "trailing-space-only change"
// Syntax competition: comment, keyword, type, boolean, string, number.
export function ReviewCard(props: ReviewProps) {
- const status: ReviewState = "pending"
- const enabled = false
- const retries = 3
- // Keep this quiet explanation readable on a tinted row.
- return <section aria-label="Old review" data-state={status}>
+ const status: ReviewState | undefined = "ready"
+ const enabled = true
+ const retries = 4
+ // Keep this revised explanation readable on a tinted row.
+ return <article aria-label="New review" data-state={status}>
<h2>{props.title}</h2>
- <span className="text-muted">{props.count} old items</span>
- <button disabled={!enabled}>Continue checkout</button>
- </section>
+ <span className="text-primary">{props.count} new items</span>
+ <button disabled={!enabled}>Confirm payment</button>
+ </article>
}
-export const longLine = "Start unchanged | The old checkout flow waits for manual confirmation before showing the receipt | Middle unchanged with punctuation: brackets [one, two], braces {three}, quotes 'four', slash /five/ | The old final instruction asks the reviewer to close the window | End unchanged"
+export const longLine = "Start unchanged | The new payment flow waits for automatic confirmation before showing the receipt | Middle unchanged with punctuation: brackets [one, two], braces {three}, quotes 'four', slash /five/ | The new final instruction asks the reviewer to keep the window | End unchanged"
-// END: baseline — last-line change
+// END: edited — last-line change
diff --git a/diff-color-fixture/02-folded.ts b/diff-color-fixture/02-folded.ts
index 07541ab49d..a5b9b354c3 100644
--- a/diff-color-fixture/02-folded.ts
+++ b/diff-color-fixture/02-folded.ts
@@ -1,5 +1,5 @@
-// BEGIN baseline
-export const first = "old"
+// BEGIN edited
+export const first = "new"
// quiet context A01
// quiet context A02
// quiet context A03
@@ -20,7 +20,7 @@ export const first = "old"
// quiet context A18
// quiet context A19
// quiet context A20
-export const second = "old"
+export const second = "new"
// quiet context B01
// quiet context B02
// quiet context B03
@@ -41,7 +41,7 @@ export const second = "old"
// quiet context B18
// quiet context B19
// quiet context B20
-export const third = "old"
+export const third = "new"
// quiet context C01
// quiet context C02
// quiet context C03
@@ -62,5 +62,5 @@ export const third = "old"
// quiet context C18
// quiet context C19
// quiet context C20
-export const fourth = "old"
-// END baseline
+export const fourth = "new"
+// END edited
diff --git a/diff-color-fixture/03-prose.md b/diff-color-fixture/03-prose.md
index 4d5b31e3b9..7d01146b6a 100644
--- a/diff-color-fixture/03-prose.md
+++ b/diff-color-fixture/03-prose.md
@@ -1,21 +1,21 @@
-# Before: plain-text review
+# After: plain-text review
Unchanged prose should remain quiet, not compete with changed rows.
-Replace one contiguous phrase: the original checkout experience stays here.
-Several little edits: red apple, cold tea, slow train.
-One character: ticket 7.
-before — the middle and the end remain unchanged.
-The start remains — before — the end remains.
-The start and the middle remain — before
-Punctuation only: hello, world!
-Whitespace only: one two three
-Trailing whitespace only.
-Delete this sentence without a replacement.
+Replace one contiguous phrase: the redesigned payment summary stays here.
+Several little edits: green apple, warm tea, fast train.
+One character: ticket 8.
+after — the middle and the end remain unchanged.
+The start remains — after — the end remains.
+The start and the middle remain — after
+Punctuation only: hello; world?
+Whitespace only: one two three
+Trailing whitespace only.
This unchanged sentence separates a deletion from an addition.
+Add this sentence without a corresponding deletion.
-Long prose: The original checkout flow asks the reader to review the old confirmation message before proceeding through the receipt screen, while the unchanged middle of this deliberately long paragraph checks whether horizontal scrolling or line wrapping keeps small inline edits visible at a narrow viewport; the final old phrase appears near the far right edge.
+Long prose: The redesigned payment flow asks the reader to review the new confirmation message before proceeding through the receipt screen, while the unchanged middle of this deliberately long paragraph checks whether horizontal scrolling or line wrapping keeps small inline edits visible at a narrow viewport; the final new phrase appears near the far right edge.
-**Syntax-like Markdown** competes with `inline code`, [links](https://example.com/old), and _emphasis_.
+**Syntax-rich Markdown** competes with `inline code`, [links](https://example.com/new), and _emphasis_.
-Final sentence before.
+Final sentence after.
diff --git a/diff-color-fixture/04-many-rows.ts b/diff-color-fixture/04-many-rows.ts
index 7a8c6ecd49..b3e544ae5f 100644
--- a/diff-color-fixture/04-many-rows.ts
+++ b/diff-color-fixture/04-many-rows.ts
@@ -10,46 +10,46 @@ export const records = [
{ id: "row-008", state: "pending", count: 8 },
{ id: "row-009", state: "pending", count: 9 },
{ id: "row-010", state: "pending", count: 10 },
- { id: "row-011", state: "pending", count: 11 },
- { id: "row-012", state: "pending", count: 12 },
- { id: "row-013", state: "pending", count: 13 },
- { id: "row-014", state: "pending", count: 14 },
- { id: "row-015", state: "pending", count: 15 },
- { id: "row-016", state: "pending", count: 16 },
- { id: "row-017", state: "pending", count: 17 },
- { id: "row-018", state: "pending", count: 18 },
- { id: "row-019", state: "pending", count: 19 },
- { id: "row-020", state: "pending", count: 20 },
- { id: "row-021", state: "pending", count: 21 },
- { id: "row-022", state: "pending", count: 22 },
- { id: "row-023", state: "pending", count: 23 },
- { id: "row-024", state: "pending", count: 24 },
- { id: "row-025", state: "pending", count: 25 },
- { id: "row-026", state: "pending", count: 26 },
- { id: "row-027", state: "pending", count: 27 },
- { id: "row-028", state: "pending", count: 28 },
- { id: "row-029", state: "pending", count: 29 },
- { id: "row-030", state: "pending", count: 30 },
- { id: "row-031", state: "pending", count: 31 },
- { id: "row-032", state: "pending", count: 32 },
- { id: "row-033", state: "pending", count: 33 },
- { id: "row-034", state: "pending", count: 34 },
- { id: "row-035", state: "pending", count: 35 },
- { id: "row-036", state: "pending", count: 36 },
- { id: "row-037", state: "pending", count: 37 },
- { id: "row-038", state: "pending", count: 38 },
- { id: "row-039", state: "pending", count: 39 },
- { id: "row-040", state: "pending", count: 40 },
- { id: "row-041", state: "pending", count: 41 },
- { id: "row-042", state: "pending", count: 42 },
- { id: "row-043", state: "pending", count: 43 },
- { id: "row-044", state: "pending", count: 44 },
- { id: "row-045", state: "pending", count: 45 },
- { id: "row-046", state: "pending", count: 46 },
- { id: "row-047", state: "pending", count: 47 },
- { id: "row-048", state: "pending", count: 48 },
- { id: "row-049", state: "pending", count: 49 },
- { id: "row-050", state: "pending", count: 50 },
+ { id: "row-011", state: "ready", count: 111 },
+ { id: "row-012", state: "ready", count: 112 },
+ { id: "row-013", state: "ready", count: 113 },
+ { id: "row-014", state: "ready", count: 114 },
+ { id: "row-015", state: "ready", count: 115 },
+ { id: "row-016", state: "ready", count: 116 },
+ { id: "row-017", state: "ready", count: 117 },
+ { id: "row-018", state: "ready", count: 118 },
+ { id: "row-019", state: "ready", count: 119 },
+ { id: "row-020", state: "ready", count: 120 },
+ { id: "row-021", state: "ready", count: 121 },
+ { id: "row-022", state: "ready", count: 122 },
+ { id: "row-023", state: "ready", count: 123 },
+ { id: "row-024", state: "ready", count: 124 },
+ { id: "row-025", state: "ready", count: 125 },
+ { id: "row-026", state: "ready", count: 126 },
+ { id: "row-027", state: "ready", count: 127 },
+ { id: "row-028", state: "ready", count: 128 },
+ { id: "row-029", state: "ready", count: 129 },
+ { id: "row-030", state: "ready", count: 130 },
+ { id: "row-031", state: "ready", count: 131 },
+ { id: "row-032", state: "ready", count: 132 },
+ { id: "row-033", state: "ready", count: 133 },
+ { id: "row-034", state: "ready", count: 134 },
+ { id: "row-035", state: "ready", count: 135 },
+ { id: "row-036", state: "ready", count: 136 },
+ { id: "row-037", state: "ready", count: 137 },
+ { id: "row-038", state: "ready", count: 138 },
+ { id: "row-039", state: "ready", count: 139 },
+ { id: "row-040", state: "ready", count: 140 },
+ { id: "row-041", state: "ready", count: 141 },
+ { id: "row-042", state: "ready", count: 142 },
+ { id: "row-043", state: "ready", count: 143 },
+ { id: "row-044", state: "ready", count: 144 },
+ { id: "row-045", state: "ready", count: 145 },
+ { id: "row-046", state: "ready", count: 146 },
+ { id: "row-047", state: "ready", count: 147 },
+ { id: "row-048", state: "ready", count: 148 },
+ { id: "row-049", state: "ready", count: 149 },
+ { id: "row-050", state: "ready", count: 150 },
{ id: "row-051", state: "pending", count: 51 },
{ id: "row-052", state: "pending", count: 52 },
{ id: "row-053", state: "pending", count: 53 },
diff --git a/diff-color-fixture/05-deleted.txt b/diff-color-fixture/05-deleted.txt
deleted file mode 100644
index e80ad6b276..0000000000
--- a/diff-color-fixture/05-deleted.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-This entire tracked file will be deleted after the baseline commit.
-Only deletion tint should be present.
-No syntax colors should compete with this text.
-Last deleted row.
diff --git a/diff-color-fixture/06-added.txt b/diff-color-fixture/06-added.txt
new file mode 100644
index 0000000000..f27ed60c9b
--- /dev/null
+++ b/diff-color-fixture/06-added.txt
@@ -0,0 +1,5 @@
+This entire file is newly added, not a replacement for the deleted example.
+Only addition tint should be present.
+Plain text: keep the words readable against the changed-row background.
+A deliberately long added line checks the horizontal extent of the green row while the reviewer compares the quiet surrounding application surface against the color used for new content in both dark and light modes.
+Last added row.
diff --git a/diff-color-fixture/07-styles.css b/diff-color-fixture/07-styles.css
index a757ef7085..4f4937480e 100644
--- a/diff-color-fixture/07-styles.css
+++ b/diff-color-fixture/07-styles.css
@@ -1,32 +1,35 @@
-/* Baseline stylesheet: colors, units, selectors, at-rules. */
+/* Edited stylesheet: colors, units, selectors, at-rules. */
:root {
- --brand: #3b5cf6;
- --radius: 6px;
+ --brand: #0b34f4;
+ --radius: 8px;
--gap: 8px;
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
.card {
- display: flex;
+ display: grid;
gap: var(--gap);
- padding: 12px 16px;
- border: 1px solid rgba(0, 0, 0, 0.1);
+ padding: 12px 20px;
+ border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: var(--radius);
background: #ffffff;
color: #161616;
+ box-shadow: var(--shadow);
}
-.card:hover {
- background: #fafafa;
+.card:hover,
+.card:focus-visible {
+ background: #f5f5f5;
}
.card[data-state="error"] {
- color: #b82d35;
+ color: #d92e3c;
border-color: #f2bbb7;
}
-@media (max-width: 768px) {
+@media (max-width: 640px) {
.card {
- flex-direction: column;
+ grid-template-columns: 1fr;
padding: 8px;
}
}
@@ -34,8 +37,10 @@
@keyframes fade-in {
from {
opacity: 0;
+ transform: translateY(4px);
}
to {
opacity: 1;
+ transform: none;
}
}
diff --git a/diff-color-fixture/08-config.json b/diff-color-fixture/08-config.json
index 09631d931c..8b6f1485fd 100644
--- a/diff-color-fixture/08-config.json
+++ b/diff-color-fixture/08-config.json
@@ -1,21 +1,22 @@
{
"name": "diff-color-fixture",
- "version": "1.0.0",
+ "version": "1.1.0",
"private": true,
"settings": {
- "theme": "light",
+ "theme": "dark",
"fontSize": 13,
- "lineHeight": 20,
- "wrap": false,
- "tabs": ["review", "files", "terminal"]
+ "lineHeight": 24,
+ "wrap": true,
+ "tabs": ["review", "files", "terminal", "browser"]
},
"features": {
"inlineHighlights": true,
"foldUnchanged": true,
- "splitView": false
+ "splitView": true,
+ "wordDiff": "word-line"
},
"limits": {
- "maxFiles": 100,
+ "maxFiles": 250,
"maxLineLength": 1000
}
}
diff --git a/diff-color-fixture/09-service.py b/diff-color-fixture/09-service.py
index 64fe32db80..5da0737cf5 100644
--- a/diff-color-fixture/09-service.py
+++ b/diff-color-fixture/09-service.py
@@ -1,36 +1,44 @@
-"""Baseline Python service: decorators, f-strings, indentation, comments."""
+"""Edited Python service: decorators, f-strings, indentation, comments."""
import asyncio
-from dataclasses import dataclass
+import logging
+from dataclasses import dataclass, field
+logger = logging.getLogger(__name__)
-@dataclass
+
+@dataclass(frozen=True)
class Review:
id: str
title: str
approved: bool = False
+ tags: list[str] = field(default_factory=list)
class ReviewService:
- def __init__(self, timeout: float = 2.0) -> None:
+ def __init__(self, timeout: float = 5.0) -> None:
self.timeout = timeout
self.cache: dict[str, Review] = {}
async def load(self, review_id: str) -> Review | None:
# Return cached reviews before touching the network.
if review_id in self.cache:
+ logger.debug("cache hit %s", review_id)
return self.cache[review_id]
- await asyncio.sleep(self.timeout)
+ try:
+ await asyncio.wait_for(asyncio.sleep(0), timeout=self.timeout)
+ except asyncio.TimeoutError:
+ return None
return None
def summary(self, review: Review) -> str:
- status = "approved" if review.approved else "pending"
- return f"Review {review.id}: {review.title} ({status})"
+ status = "approved" if review.approved else "needs review"
+ return f"Review {review.id}: {review.title!r} ({status})"
def main() -> None:
service = ReviewService()
- review = Review(id="r-1", title="Checkout")
+ review = Review(id="r-1", title="Checkout", tags=["ui"])
print(service.summary(review))
diff --git a/diff-color-fixture/10-moved-block.ts b/diff-color-fixture/10-moved-block.ts
index fbd5ecba84..214094d8eb 100644
--- a/diff-color-fixture/10-moved-block.ts
+++ b/diff-color-fixture/10-moved-block.ts
@@ -3,11 +3,6 @@ export function first() {
return "first"
}
-export function second() {
- const values = [1, 2, 3]
- return values.map((value) => value * 2)
-}
-
export function third() {
return "third"
}
@@ -16,4 +11,9 @@ export function fourth() {
return "fourth"
}
-export const order = ["first", "second", "third", "fourth"]
+export function second() {
+ const values = [1, 2, 3]
+ return values.map((value) => value * 2)
+}
+
+export const order = ["first", "third", "fourth", "second"]
diff --git a/diff-color-fixture/11-deploy.sh b/diff-color-fixture/11-deploy.sh
index 5390c2fe9f..ae367067d6 100644
--- a/diff-color-fixture/11-deploy.sh
+++ b/diff-color-fixture/11-deploy.sh
@@ -1,17 +1,20 @@
#!/usr/bin/env bash
-# Baseline shell script: variables, quoting, conditionals.
+# Edited shell script: variables, quoting, conditionals.
set -euo pipefail
ENVIRONMENT="${1:-staging}"
-REGION="us-east-1"
-RETRIES=3
+REGION="${REGION:-eu-west-1}"
+RETRIES=5
log() {
- echo "[deploy] $*"
+ printf '[deploy] %s\n' "$*" >&2
}
if [[ "$ENVIRONMENT" == "production" ]]; then
log "Deploying to production in $REGION"
+elif [[ "$ENVIRONMENT" == "preview" ]]; then
+ log "Skipping preview deploy"
+ exit 0
else
log "Deploying to $ENVIRONMENT"
fi
diff --git a/diff-color-fixture/12-pipeline.yml b/diff-color-fixture/12-pipeline.yml
index 1fbd5aa75c..251a7e4372 100644
--- a/diff-color-fixture/12-pipeline.yml
+++ b/diff-color-fixture/12-pipeline.yml
@@ -1,19 +1,20 @@
name: review
on:
push:
- branches: [v2]
+ branches: [v2, dev]
pull_request:
jobs:
check:
- runs-on: ubuntu-latest
- timeout-minutes: 10
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
steps:
- uses: actions/checkout@v4
+ - uses: oven-sh/setup-bun@v2
- name: Install
- run: bun install
+ run: bun install --frozen-lockfile
- name: Check
run: bun run check
env:
CI: true
- LOG_LEVEL: info
+ LOG_LEVEL: debug
diff --git a/diff-color-fixture/13-unicode.md b/diff-color-fixture/13-unicode.md
index 1470ef9294..5bd43c3b1f 100644
--- a/diff-color-fixture/13-unicode.md
+++ b/diff-color-fixture/13-unicode.md
@@ -1,12 +1,12 @@
# Unicode and mixed scripts
-Emoji: ✅ ready, ⚠️ warning, ❌ failed.
-CJK: 代码审查 完成。
-Japanese: レビューを開始します。
+Emoji: ✅ ready, ⚠️ warning, ❌ failed, 🚀 shipped.
+CJK: 代码审查 已完成。
+Japanese: レビューを完了しました。
Korean: 변경 사항을 검토합니다.
-Arabic (RTL): مراجعة التغييرات
+Arabic (RTL): مراجعة جميع التغييرات
Hebrew (RTL): סקירת שינויים
-Accents: café, naïve, résumé.
-Math: a ≤ b, x ≠ y, ∑ values.
+Accents: café, naïve, résumé, déjà vu.
+Math: a ≥ b, x ≠ y, ∏ values.
Box: ┌─┐ │ │ └─┘
Combining: e\u0301 vs é
diff --git a/diff-color-fixture/14-tabs.go b/diff-color-fixture/14-tabs.go
index ab9aca86dd..53e5e11a7c 100644
--- a/diff-color-fixture/14-tabs.go
+++ b/diff-color-fixture/14-tabs.go
@@ -7,11 +7,12 @@ type Review struct {
ID string
Title string
Approved bool
+ Tags []string
}
func (r Review) Summary() string {
status := "pending"
- if r.Approved {
+ if r.Approved {
status = "approved"
}
return fmt.Sprintf("%s: %s (%s)", r.ID, r.Title, status)
diff --git a/diff-color-fixture/15-empty-to-content.txt b/diff-color-fixture/15-empty-to-content.txt
index e69de29bb2..aa4be0acdb 100644
--- a/diff-color-fixture/15-empty-to-content.txt
+++ b/diff-color-fixture/15-empty-to-content.txt
@@ -0,0 +1,2 @@
+This tracked file was empty in the baseline.
+Every row should render as an addition, without a file-added badge.
diff --git a/diff-color-fixture/16-content-to-empty.txt b/diff-color-fixture/16-content-to-empty.txt
index bb6e474b37..e69de29bb2 100644
--- a/diff-color-fixture/16-content-to-empty.txt
+++ b/diff-color-fixture/16-content-to-empty.txt
@@ -1,3 +0,0 @@
-This tracked file keeps existing but loses all content.
-Every row should render as a deletion.
-The file itself is not deleted.
diff --git a/diff-color-fixture/17-no-newline.txt b/diff-color-fixture/17-no-newline.txt
index 51b84a61e2..dc6945a1fa 100644
--- a/diff-color-fixture/17-no-newline.txt
+++ b/diff-color-fixture/17-no-newline.txt
@@ -1,2 +1,2 @@
First line
-Last line without trailing newline
\ No newline at end of file
+Last line now with trailing newline
diff --git a/diff-color-fixture/18-page.html b/diff-color-fixture/18-page.html
index bc1b2d9daa..9f4df11c24 100644
--- a/diff-color-fixture/18-page.html
+++ b/diff-color-fixture/18-page.html
@@ -2,13 +2,14 @@
<html lang="en">
<head>
<meta charset="utf-8" />
- <title>Review</title>
+ <meta name="viewport" content="width=device-width" />
+ <title>Review changes</title>
</head>
- <body class="light">
- <main id="app" data-state="idle">
- <h1>Old heading</h1>
- <button type="button" disabled>Continue</button>
- <!-- baseline comment -->
+ <body class="dark">
+ <main id="app" data-state="ready">
+ <h1>New heading</h1>
+ <button type="button">Continue</button>
+ <!-- edited comment -->
</main>
</body>
</html>
diff --git a/diff-color-fixture/19-query.sql b/diff-color-fixture/19-query.sql
index b078ad0869..88b64001e0 100644
--- a/diff-color-fixture/19-query.sql
+++ b/diff-color-fixture/19-query.sql
@@ -1,6 +1,6 @@
-SELECT id, title, status
+SELECT id, title, status, author_id
FROM reviews
-WHERE status = 'pending'
- AND created_at > NOW() - INTERVAL '7 days'
-ORDER BY created_at DESC
-LIMIT 50;
+WHERE status IN ('pending', 'blocked')
+ AND created_at > NOW() - INTERVAL '14 days'
+ORDER BY updated_at DESC
+LIMIT 100;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

@@ -1 +0,0 @@
local.json
@@ -1,697 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Diff color tuner</title>
<style>
:root {
color-scheme: light dark;
--bg: light-dark(#fafafa, #121212);
--panel: light-dark(#ffffff, #1b1b1b);
--line: light-dark(#e6e6e6, #2c2c2c);
--text: light-dark(#161616, #ededed);
--muted: light-dark(#6b6b6b, #9a9a9a);
--accent: #3b5cf6;
font: 13px/20px Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); }
header {
position: sticky; top: 0; z-index: 5; display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
padding: 12px 16px; background: var(--panel); border-bottom: 1px solid var(--line);
}
header h1 { font-size: 14px; line-height: 20px; margin: 0 8px 0 0; font-weight: 600; }
button, select, input[type="text"] {
font: inherit; color: inherit; background: var(--panel); border: 1px solid var(--line);
border-radius: 6px; height: 28px; padding: 0 8px;
}
button { cursor: default; }
button:hover { background: light-dark(#f2f2f2, #242424); }
label.toggle { display: inline-flex; gap: 6px; align-items: center; }
#status { color: var(--muted); margin-left: auto; font-variant-numeric: tabular-nums; }
main { padding: 12px 16px 48px; max-width: 1180px; }
h2 { font-size: 12px; line-height: 16px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin: 20px 0 8px; font-weight: 600; }
dialog { width: min(720px, calc(100vw - 32px)); padding: 16px; color: var(--text); background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 12px 32px #0004; }
.sets { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 4px 0 8px; }
.sets label { font-weight: 600; }
#set { min-width: 200px; }
#name-input { width: 100%; margin: 8px 0 12px; }
dialog::backdrop { background: #0006; }
dialog h3 { margin: 0 0 4px; font-size: 14px; line-height: 20px; }
#paste-text { width: 100%; height: 280px; margin-top: 8px; padding: 8px; resize: vertical; color: var(--text); background: var(--bg);
border: 1px solid var(--line); border-radius: 6px; font: 12px/18px "IBM Plex Mono", ui-monospace, monospace; }
#paste-result { min-height: 16px; }
#paste-result[data-error] { color: #d92e3c; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
h2 { display: flex; align-items: center; gap: 12px; }
.group-toggle { text-transform: none; letter-spacing: 0; font-weight: 500; font-size: 12px; color: var(--text); }
.grid.off { opacity: .45; }
.grid { display: grid; grid-template-columns: minmax(220px, 1.1fr) 1fr 1fr; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); overflow: hidden; }
.grid > div { padding: 8px 10px; border-top: 1px solid var(--line); min-width: 0; }
.grid > .head { border-top: 0; font-weight: 600; color: var(--muted); font-size: 12px; line-height: 16px; }
.slot-label { font-weight: 500; }
.slot-help { color: var(--muted); font-size: 12px; line-height: 16px; }
.cell { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.cell[data-mode="dark"] { background: light-dark(#f6f6f6, #161616); }
.swatch { width: 28px; height: 28px; border-radius: 6px; border: 1px solid var(--line); flex: none;
background-image: linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%), linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%);
background-size: 8px 8px; background-position: 0 0, 4px 4px; position: relative; overflow: hidden; }
.swatch::after { content: ""; position: absolute; inset: 0; background: var(--swatch, transparent); }
.swatch[data-current]::after { background: repeating-linear-gradient(-45deg, transparent 0 4px, light-dark(#0001, #fff1) 4px 8px); }
input[type="color"] { width: 36px; height: 28px; padding: 0 2px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); }
input[type="range"] { width: 90px; }
button.token { display: inline-flex; align-items: center; gap: 6px; min-width: 200px; max-width: 100%; text-align: left; }
button.token .token-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 12px/16px "IBM Plex Mono", ui-monospace, monospace; }
button.token .caret { color: var(--muted); }
.mini { width: 14px; height: 14px; border-radius: 3px; border: 1px solid var(--line); background: var(--swatch, transparent); flex: none; }
#picker { position: fixed; z-index: 50; width: 560px; max-width: calc(100vw - 16px); max-height: min(520px, calc(100vh - 16px)); display: flex; flex-direction: column;
background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 12px 32px #0003; }
.picker-head { display: flex; gap: 8px; align-items: center; padding: 8px; border-bottom: 1px solid var(--line); }
#picker-search { flex: 1; }
#picker-mode { color: var(--muted); font-size: 12px; }
#picker-body { overflow: auto; padding: 4px 8px 8px; }
.family { margin-top: 8px; }
.family-name { color: var(--muted); font-size: 11px; line-height: 16px; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 4px; }
.chips { display: flex; flex-wrap: wrap; gap: 4px; }
.chip { width: 36px; height: 28px; padding: 0; border-radius: 5px; border: 1px solid var(--line); position: relative; overflow: hidden;
background-image: linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%), linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%);
background-size: 8px 8px; background-position: 0 0, 4px 4px; }
.chip::after { content: ""; position: absolute; inset: 0; background: var(--swatch); }
.chip span { position: absolute; z-index: 1; inset: auto 0 1px; font-size: 9px; line-height: 10px; text-align: center; color: var(--chip-text); }
.chip[aria-selected="true"] { outline: 2px solid var(--accent); outline-offset: 1px; }
.rows { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 8px; }
.row { display: flex; align-items: center; gap: 8px; height: 28px; padding: 0 6px; border: 0; background: transparent; text-align: left; min-width: 0; }
.row:hover, .row[aria-selected="true"] { background: light-dark(#f0f0f0, #262626); }
.row[aria-selected="true"] { box-shadow: inset 0 0 0 1px var(--accent); }
.row .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 12px/16px "IBM Plex Mono", ui-monospace, monospace; }
.alpha { display: inline-flex; align-items: center; gap: 2px; color: var(--muted); }
.alpha-input { width: 56px; height: 28px; padding: 0 6px; font: inherit; font-variant-numeric: tabular-nums; color: var(--text);
background: var(--panel); border: 1px solid var(--line); border-radius: 6px; text-align: right; }
.hidden { display: none !important; }
pre { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 12px; overflow: auto; font: 12px/18px "IBM Plex Mono", ui-monospace, monospace; max-height: 360px; }
.note { color: var(--muted); max-width: 820px; }
</style>
</head>
<body>
<header>
<h1>Diff color tuner</h1>
<label class="toggle"><input type="checkbox" id="enabled" checked /> Overrides on</label>
<button id="undo" disabled title="Undo last change (⌘Z)">Undo</button>
<button id="redo" disabled title="Redo (⇧⌘Z)">Redo</button>
<button id="reset">Reset all</button>
<button id="copy">Copy CSS</button>
<button id="paste">Paste CSS</button>
<span id="status">Loading…</span>
</header>
<main>
<div class="sets">
<label for="set">Override set</label>
<select id="set"></select>
<button id="set-new">New</button>
<button id="set-duplicate">Duplicate</button>
<button id="set-rename">Rename</button>
<button id="set-delete">Delete</button>
<span id="set-note" class="slot-help"></span>
</div>
<p class="note">
Each change is pushed live into open diff views in the dev app and saved to
<code>packages/session-ui/src/pierre/diff-color-tuning.ts</code>. “Current” leaves the branch's colors untouched.
Picked fills are exact final colors; tokens resolve per mode from OpenCode v2 tokens.
</p>
<div id="groups"></div>
<h2>Generated CSS</h2>
<pre id="css"></pre>
</main>
<dialog id="name-dialog">
<form method="dialog">
<h3 id="name-title">Name</h3>
<input type="text" id="name-input" spellcheck="false" autocomplete="off" />
<div class="dialog-actions">
<button value="cancel">Cancel</button>
<button id="name-ok" value="ok" class="primary">Save</button>
</div>
</form>
</dialog>
<dialog id="paste-dialog">
<form method="dialog">
<h3>Paste CSS</h3>
<p class="slot-help">Paste CSS copied from this tuner. It replaces the current settings; Undo restores them.</p>
<textarea id="paste-text" spellcheck="false" placeholder="/* diff-color-tuner: … */"></textarea>
<p id="paste-result" class="slot-help"></p>
<div class="dialog-actions">
<button value="cancel">Cancel</button>
<button id="paste-apply" value="apply" class="primary">Apply</button>
</div>
</form>
</dialog>
<div id="picker" class="hidden" role="dialog" aria-label="Choose a v2 token">
<div class="picker-head">
<input type="text" id="picker-search" placeholder="Search tokens, e.g. red, state, 1200" spellcheck="false" autocomplete="off" />
<span id="picker-mode"></span>
</div>
<div id="picker-body"></div>
</div>
<script type="module">
const LIGHT = ':host(:not([data-color-scheme="dark"]))'
const DARK = ':host([data-color-scheme="dark"])'
const ADD = '[data-line-type="change-addition"]'
const DEL = '[data-line-type="change-deletion"]'
const CONTEXT = ':is([data-line-type="context"], [data-line-type="context-expanded"])'
const ROW = ':is([data-line], [data-no-newline])'
const GUTTER = ':is([data-column-number], [data-gutter-buffer])'
const exactRow = (sel) => (host, v) => `${host} ${sel} { --diffs-diff-line-mix-target: ${v}; --mix-light: 0%; --mix-dark: 0%; }`
const hostVar = (name) => (host, v) => `${host} { ${name}: ${v}; }`
const scopedVar = (name) => (host, v) => `${host} :is([data-diff], [data-file]) { ${name}: ${v}; }`
const direct = (sel, decl) => (host, v) => `${host} ${sel} { ${decl(v)} }`
// Syntax colors are inline style attributes on the token spans, so only !important can override them.
const inlineText = (line) => (host, v) =>
`${host} ${line} [data-diff-span], ${host} ${line} [data-diff-span] * { color: ${v} !important; }`
const GROUPS = [
["Backgrounds", [
{ id: "bg", label: "File background", help: "--diffs-bg. Base for context rows and Pierre's derived mixes.", rule: hostVar("--diffs-bg"), light: "v2-grey-50", dark: "v2-grey-1100" },
{ id: "context-row", label: "Unchanged row", help: "Context code rows.", rule: exactRow(`${CONTEXT}${ROW}`), light: "v2-grey-50", dark: "v2-grey-1100" },
{ id: "context-gutter", label: "Unchanged gutter", help: "Context line-number cells.", rule: exactRow(`${CONTEXT}${GUTTER}`), light: "v2-grey-50", dark: "v2-grey-1100" },
{ id: "fold-row", label: "Folded “unmodified lines” row", help: "--diffs-bg-separator-override.", rule: hostVar("--diffs-bg-separator-override"), light: "v2-grey-100", dark: "v2-grey-1000" },
{ id: "buffer-bg", label: "Split empty side", help: "--diffs-bg-context-gutter-override (blank side of split rows).", rule: hostVar("--diffs-bg-context-gutter-override"), light: "v2-grey-100", dark: "v2-grey-1000" },
{ id: "buffer-hatch", label: "Split empty side hatch", help: "--diffs-bg-buffer-override (Pierre stripes; OpenCode currently hides them).", rule: hostVar("--diffs-bg-buffer-override"), light: "v2-grey-200", dark: "v2-grey-900" },
{ id: "hover", label: "Hover tint", help: "--diffs-bg-hover-override. Mixed in at ~3% light / ~9% dark, not exact.", rule: hostVar("--diffs-bg-hover-override"), light: "v2-grey-1200", dark: "v2-grey-50" },
{ id: "selection", label: "Selected lines", help: "--diffs-selection-base (row and number tints derive from it).", rule: scopedVar("--diffs-selection-base"), light: "v2-background-bg-accent", dark: "v2-background-bg-accent" },
{ id: "comment", label: "Comment annotation", help: "--diffs-comment-bg.", rule: scopedVar("--diffs-comment-bg"), light: "v2-blue-100", dark: "v2-blue-1200" },
]],
["Additions", [
{ id: "add-row", label: "Added row", help: "Exact fill of added code rows.", rule: exactRow(`${ADD}${ROW}`), light: "v2-green-100", dark: "v2-green-1200" },
{ id: "add-gutter", label: "Added gutter", help: "Exact fill of added line-number cells.", rule: exactRow(`${ADD}${GUTTER}`), light: "v2-green-100", dark: "v2-green-1200" },
{ id: "add-inline", label: "Added inline highlight", help: "Word/char emphasis span, painted over the row.", rule: direct(`${ADD} [data-diff-span]`, (v) => `background-color: ${v};`), light: "v2-green-200", dark: "v2-green-1100" },
{ id: "add-inline-text", label: "Added inline text", help: "Text color inside the highlight. Replaces syntax colors there.", rule: inlineText(ADD), light: "v2-green-1000", dark: "v2-green-300" },
{ id: "add-bar", label: "Added change bar", help: "2px gutter bar.", rule: direct(`[data-column-number]${ADD}::before`, (v) => `background-color: ${v}; background-image: none;`), light: "v2-state-fg-success", dark: "v2-state-fg-success" },
{ id: "add-number", label: "Added line-number text", help: "--diffs-fg-number-addition-override.", rule: hostVar("--diffs-fg-number-addition-override"), light: "v2-state-fg-success", dark: "v2-state-fg-success" },
{ id: "add-seed", label: "Addition seed", help: "--diffs-addition-color-override. Feeds anything above left on Current.", rule: hostVar("--diffs-addition-color-override"), light: "v2-state-fg-success", dark: "v2-state-fg-success" },
]],
["Deletions", [
{ id: "del-row", label: "Deleted row", help: "Exact fill of deleted code rows.", rule: exactRow(`${DEL}${ROW}`), light: "v2-red-100", dark: "v2-red-1200" },
{ id: "del-gutter", label: "Deleted gutter", help: "Exact fill of deleted line-number cells.", rule: exactRow(`${DEL}${GUTTER}`), light: "v2-red-100", dark: "v2-red-1200" },
{ id: "del-inline", label: "Deleted inline highlight", help: "Word/char emphasis span, painted over the row.", rule: direct(`${DEL} [data-diff-span]`, (v) => `background-color: ${v};`), light: "v2-red-200", dark: "v2-red-1100" },
{ id: "del-inline-text", label: "Deleted inline text", help: "Text color inside the highlight. Replaces syntax colors there.", rule: inlineText(DEL), light: "v2-red-1000", dark: "v2-red-300" },
{ id: "del-bar", label: "Deleted change bar", help: "2px gutter bar.", rule: direct(`[data-column-number]${DEL}::before`, (v) => `background-color: ${v}; background-image: none;`), light: "v2-state-fg-danger", dark: "v2-state-fg-danger" },
{ id: "del-number", label: "Deleted line-number text", help: "--diffs-fg-number-deletion-override.", rule: hostVar("--diffs-fg-number-deletion-override"), light: "v2-state-fg-danger", dark: "v2-state-fg-danger" },
{ id: "del-seed", label: "Deletion seed", help: "--diffs-deletion-color-override. Feeds anything above left on Current.", rule: hostVar("--diffs-deletion-color-override"), light: "v2-state-fg-danger", dark: "v2-state-fg-danger" },
]],
["Text", [
{ id: "number", label: "Unchanged line-number text", help: "--diffs-fg-number-override. Also fold labels unless set below.", rule: hostVar("--diffs-fg-number-override"), light: "v2-text-text-faint", dark: "v2-text-text-faint" },
{ id: "fold-text", label: "Fold label and expand icon", help: "“N unmodified lines” text and expand button.", rule: direct(":is([data-separator-content], [data-expand-button])", (v) => `color: ${v};`), light: "v2-text-text-muted", dark: "v2-text-text-muted" },
]],
]
const SLOTS = GROUPS.flatMap((group) => group[1])
let tokens = []
let tokenMap = new Map()
let state = { enabled: true, slots: {} }
let liveTimer, saveTimer, liveBusy = false, livePending = false
const $ = (sel) => document.querySelector(sel)
const status = (text) => ($("#status").textContent = text)
function hex8(hex, alpha) {
return alpha >= 100 ? hex : hex + Math.round((alpha / 100) * 255).toString(16).padStart(2, "0")
}
function tokenHex(name, mode) {
const value = tokenMap.get(name)?.[mode]
return value && /^#[0-9a-f]{6}/i.test(value) ? value.slice(0, 7) : "#888888"
}
function valueOf(entry) {
if (!entry || entry.kind === "current") return null
if (entry.kind === "color") return hex8(entry.hex, entry.alpha)
if (!tokenMap.has(entry.token)) return null
return entry.alpha >= 100 ? `var(--${entry.token})` : `rgb(from var(--${entry.token}) r g b / ${entry.alpha / 100})`
}
function swatchOf(entry, mode) {
if (!entry || entry.kind === "current") return null
if (entry.kind === "color") return hex8(entry.hex, entry.alpha)
const value = tokenMap.get(entry.token)?.[mode]
if (!value) return null
return entry.alpha >= 100 ? value : `rgb(from ${value} r g b / ${entry.alpha / 100})`
}
function buildCSS() {
if (!state.enabled) return ""
const active = GROUPS.filter(([title]) => !state.disabledGroups?.[title]).flatMap((group) => group[1])
return [["light", LIGHT], ["dark", DARK]]
.flatMap(([mode, host]) =>
active.flatMap((slot) => {
const value = valueOf(state.slots[slot.id]?.[mode])
return value ? [`/* ${mode}: ${slot.label} */\n${slot.rule(host, value)}`] : []
}),
)
.join("\n")
}
function entryFor(slot, mode) {
state.slots[slot.id] ??= {}
state.slots[slot.id][mode] ??= { kind: "current" }
return state.slots[slot.id][mode]
}
function render() {
const root = $("#groups")
root.textContent = ""
for (const [title, slots] of GROUPS) {
const heading = document.createElement("h2")
heading.innerHTML = `<span></span><label class="toggle group-toggle"><input type="checkbox" /> <span>On</span></label>`
heading.firstElementChild.textContent = title
const enabled = !state.disabledGroups?.[title]
const toggle = heading.querySelector("input")
toggle.checked = enabled
heading.querySelector(".group-toggle span").textContent = enabled ? "On" : "Off"
toggle.onchange = () => {
state.disabledGroups ??= {}
if (toggle.checked) delete state.disabledGroups[title]
else state.disabledGroups[title] = true
render()
changed(true)
}
const grid = document.createElement("div")
grid.className = "grid"
grid.classList.toggle("off", !enabled)
grid.innerHTML = `<div class="head">Slot</div><div class="head">Light</div><div class="head">Dark</div>`
for (const slot of slots) {
const info = document.createElement("div")
info.innerHTML = `<div class="slot-label"></div><div class="slot-help"></div>`
info.children[0].textContent = slot.label
info.children[1].textContent = slot.help
grid.append(info, cell(slot, "light"), cell(slot, "dark"))
}
root.append(heading, grid)
}
$("#enabled").checked = state.enabled
$("#css").textContent = buildCSS() || "/* No overrides */"
}
function cell(slot, mode) {
const entry = entryFor(slot, mode)
const el = document.createElement("div")
el.className = "cell"
el.dataset.mode = mode
el.innerHTML = `
<span class="swatch"></span>
<select class="kind"><option value="current">Current</option><option value="color">Color</option><option value="token">v2 token</option></select>
<input type="color" class="hex" />
<button type="button" class="token"><span class="mini"></span><span class="token-name"></span><span class="caret">▾</span></button>
<input type="range" class="alpha-range" min="0" max="100" step="1" />
<span class="alpha"><input type="number" class="alpha-input" min="0" max="100" step="any" aria-label="Opacity percent" /><span>%</span></span>`
const swatch = el.querySelector(".swatch")
const kind = el.querySelector(".kind")
const hex = el.querySelector(".hex")
const token = el.querySelector(".token")
const alpha = el.querySelector(".alpha-range")
const alphaText = el.querySelector(".alpha")
const alphaInput = el.querySelector(".alpha-input")
const sync = () => {
kind.value = entry.kind
hex.classList.toggle("hidden", entry.kind !== "color")
token.classList.toggle("hidden", entry.kind !== "token")
alpha.classList.toggle("hidden", entry.kind === "current")
alphaText.classList.toggle("hidden", entry.kind === "current")
if (entry.kind === "color") hex.value = entry.hex
if (entry.kind === "token") {
token.querySelector(".token-name").textContent = entry.token
token.querySelector(".mini").style.setProperty("--swatch", tokenMap.get(entry.token)?.[mode] ?? "transparent")
}
alpha.value = entry.alpha ?? 100
if (document.activeElement !== alphaInput) alphaInput.value = entry.alpha ?? 100
const color = swatchOf(entry, mode)
if (color) swatch.style.setProperty("--swatch", color)
swatch.toggleAttribute("data-current", !color)
swatch.title = entry.kind === "token" ? `${entry.token}: ${tokenMap.get(entry.token)?.[mode] ?? "unknown token"}` : (color ?? "Current (unchanged)")
token.style.outline = entry.kind === "token" && !tokenMap.has(entry.token) ? "1px solid #d92e3c" : ""
}
kind.onchange = () => {
const previous = { ...entry }
entry.kind = kind.value
entry.alpha ??= 100
if (entry.kind === "color") entry.hex = previous.kind === "token" ? tokenHex(previous.token, mode) : (previous.hex ?? tokenHex(slot[mode], mode))
if (entry.kind === "token") entry.token = previous.token ?? slot[mode]
sync()
changed(true)
}
hex.oninput = () => { entry.hex = hex.value; sync(); changed(false) }
hex.onchange = () => changed(true)
token.onclick = () => openPicker(token, mode, entry.token, (name) => { entry.token = name; sync(); changed(true) })
alpha.oninput = () => { entry.alpha = Number(alpha.value); sync(); changed(false) }
alpha.onchange = () => changed(true)
alphaInput.oninput = () => {
const value = Number(alphaInput.value)
if (alphaInput.value === "" || !Number.isFinite(value)) return
entry.alpha = Math.min(100, Math.max(0, value))
sync()
changed(false)
}
alphaInput.onchange = () => { alphaInput.value = entry.alpha ?? 100; changed(true) }
alphaInput.onkeydown = (event) => { if (event.key === "Enter") alphaInput.blur() }
sync()
return el
}
const RAMP_ORDER = ["grey", "red", "orange", "yellow", "green", "cyan", "blue", "purple", "pink", "alpha-dark", "alpha-light"]
const picker = { anchor: null, mode: "light", selected: "", onPick: null }
function familyOf(name) {
const ramp = /^v2-([a-z]+(?:-dark|-light)?)-(\d+)$/.exec(name)
if (ramp && RAMP_ORDER.includes(ramp[1])) return { family: ramp[1], step: ramp[2], ramp: true }
return { family: name.split("-")[1] ?? "other", step: "", ramp: false }
}
function readableOn(value) {
const hex = /^#([0-9a-f]{6})/i.exec(value)?.[1]
if (!hex) return "#000"
const [r, g, b] = [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.55 ? "#0009" : "#fffb"
}
function openPicker(anchor, mode, selected, onPick) {
Object.assign(picker, { anchor, mode, selected, onPick })
$("#picker-mode").textContent = `${mode} values`
$("#picker-search").value = ""
$("#picker").classList.remove("hidden")
renderPicker()
placePicker()
$("#picker-search").focus()
const current = $(`#picker-body [aria-selected="true"]`)
$("#picker-body").scrollTop = current ? current.offsetTop - $("#picker-body").offsetTop - 60 : 0
}
function closePicker() {
$("#picker").classList.add("hidden")
picker.anchor?.focus()
picker.anchor = null
}
function placePicker() {
const el = $("#picker")
const rect = picker.anchor.getBoundingClientRect()
const height = el.offsetHeight
const top = rect.bottom + 4 + height > innerHeight ? Math.max(8, rect.top - 4 - height) : rect.bottom + 4
el.style.top = `${top}px`
el.style.left = `${Math.min(Math.max(8, rect.left), innerWidth - el.offsetWidth - 8)}px`
}
function renderPicker() {
const terms = $("#picker-search").value.toLowerCase().split(/\s+/).filter(Boolean)
const matches = tokens.filter((token) => terms.every((term) => token.name.includes(term)))
const groups = new Map()
for (const token of matches) {
const info = familyOf(token.name)
if (!groups.has(info.family)) groups.set(info.family, { ramp: info.ramp, items: [] })
groups.get(info.family).items.push({ token, step: info.step })
}
const order = [...groups.keys()].sort((a, b) => {
const ia = RAMP_ORDER.indexOf(a), ib = RAMP_ORDER.indexOf(b)
if (ia !== -1 || ib !== -1) return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib)
return a.localeCompare(b)
})
const body = $("#picker-body")
body.textContent = ""
if (!matches.length) body.innerHTML = `<p class="slot-help">No tokens match.</p>`
for (const family of order) {
const group = groups.get(family)
const section = document.createElement("div")
section.className = "family"
const title = document.createElement("div")
title.className = "family-name"
title.textContent = family
const list = document.createElement("div")
list.className = group.ramp ? "chips" : "rows"
for (const item of group.items) {
const value = item.token[picker.mode]
const button = document.createElement("button")
button.type = "button"
button.title = `${item.token.name}\nlight ${item.token.light}\ndark ${item.token.dark}`
button.setAttribute("aria-selected", String(item.token.name === picker.selected))
button.style.setProperty("--swatch", value)
if (group.ramp) {
button.className = "chip"
button.style.setProperty("--chip-text", readableOn(value))
button.innerHTML = `<span>${item.step}</span>`
} else {
button.className = "row"
button.innerHTML = `<span class="mini"></span><span class="name"></span>`
button.querySelector(".name").textContent = item.token.name.replace(/^v2-/, "")
}
button.onclick = () => {
picker.onPick(item.token.name)
closePicker()
}
list.append(button)
}
section.append(title, list)
body.append(section)
}
}
$("#picker-search").oninput = renderPicker
$("#picker-search").onkeydown = (event) => {
if (event.key === "Escape") closePicker()
if (event.key === "Enter") $("#picker-body button")?.click()
}
document.addEventListener("mousedown", (event) => {
if (!picker.anchor || $("#picker").contains(event.target) || picker.anchor.contains(event.target)) return
closePicker()
})
window.addEventListener("resize", () => picker.anchor && placePicker())
document.addEventListener("scroll", () => picker.anchor && placePicker(), true)
// History holds committed snapshots only, so one drag of a picker or slider is one undo step.
const history = { undo: [], redo: [], committed: "" }
function record() {
const snapshot = JSON.stringify(state)
if (snapshot === history.committed) return
history.undo.push(history.committed)
history.redo = []
history.committed = snapshot
syncHistory()
}
function travel(from, to) {
if (!from.length) return
to.push(history.committed)
history.committed = from.pop()
state = JSON.parse(history.committed)
render()
syncHistory()
changed(false)
clearTimeout(saveTimer)
saveTimer = setTimeout(save, 150)
}
function syncHistory() {
$("#undo").disabled = !history.undo.length
$("#redo").disabled = !history.redo.length
}
$("#undo").onclick = () => travel(history.undo, history.redo)
$("#redo").onclick = () => travel(history.redo, history.undo)
document.addEventListener("keydown", (event) => {
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "z") return
if (event.target instanceof HTMLInputElement && ["text", "number"].includes(event.target.type)) return
event.preventDefault()
if (event.shiftKey) travel(history.redo, history.undo)
else travel(history.undo, history.redo)
})
// Live pushes stay responsive while dragging; file writes are debounced to limit dev-server hot reloads.
function changed(commit) {
if (commit) record()
const css = buildCSS()
$("#css").textContent = css || "/* No overrides */"
clearTimeout(liveTimer)
liveTimer = setTimeout(() => live(css), 30)
clearTimeout(saveTimer)
saveTimer = setTimeout(save, commit ? 150 : 700)
status("Saving…")
}
async function live(css) {
if (liveBusy) { livePending = true; return }
liveBusy = true
await fetch("/api/live", { method: "POST", body: JSON.stringify({ css }) }).catch(() => {})
liveBusy = false
if (livePending) { livePending = false; live(buildCSS()) }
}
async function save() {
// The committed reference set is read-only; the first edit forks it so the reference stays intact.
if (current.readonly) {
const name = `${state.name} copy`
const created = await fetch("/api/sets", { method: "POST", body: JSON.stringify({ name, set: state }) }).then((r) => r.json())
state.name = name
state.readonly = false
await loadSets(created.id)
$("#set-note").textContent = `Reference is read-only, so your edits were saved as “${name}”.`
}
const response = await fetch(`/api/sets/${current.id}`, { method: "PUT", body: JSON.stringify({ set: state, css: buildCSS() }) }).catch(() => null)
if (!response?.ok) return status("Save failed — is server.ts running?")
const result = await response.json()
status(`Saved ${new Date().toLocaleTimeString()} · live in ${result.live} diff view${result.live === 1 ? "" : "s"}`)
}
$("#enabled").onchange = () => { state.enabled = $("#enabled").checked; render(); changed(true) }
$("#reset").onclick = () => { if (!confirm("Reset every slot to Current?")) return; state = { ...state, enabled: true, disabledGroups: {}, slots: {} }; render(); changed(true) }
// Shared CSS carries the exact settings in a header so pasting restores token choices, opacity and category toggles.
function shareableCSS() {
const css = buildCSS()
if (!css) return ""
const slots = Object.fromEntries(
Object.entries(state.slots).flatMap(([id, modes]) => {
const active = Object.fromEntries(Object.entries(modes).filter(([, entry]) => entry.kind !== "current"))
return Object.keys(active).length ? [[id, active]] : []
}),
)
const settings = { enabled: state.enabled, disabledGroups: state.disabledGroups ?? {}, slots }
return `/* diff-color-tuner: ${JSON.stringify(settings)} */\n${css}`
}
function parseShared(text) {
// Chat apps often render "*…*" as Markdown emphasis and drop the asterisks, so don't require comment delimiters.
// The settings JSON is always on one line, so a greedy match within the line is safe.
const header = /diff-color-tuner:\s*(\{.*\})/.exec(text)
if (header) {
const settings = JSON.parse(header[1])
const slots = Object.fromEntries(Object.entries(settings.slots ?? {}).filter(([id]) => SLOTS.some((slot) => slot.id === id)))
return { state: { enabled: settings.enabled ?? true, disabledGroups: settings.disabledGroups ?? {}, slots }, count: Object.values(slots).reduce((sum, modes) => sum + Object.keys(modes).length, 0) }
}
// Fallback for CSS without the header: read each "/* mode: Label */" comment and the first color value after it.
const slots = {}
let count = 0
for (const match of text.matchAll(/^\s*\/\*?\s*(light|dark):\s*(.+?)\s*\*?\/\s*\n\s*([^\n]+)/gm)) {
const label = (value) => value.replace(/[“”"]/g, '"').replace(/\s*\*$/, "")
const slot = SLOTS.find((candidate) => label(candidate.label) === label(match[2]))
const entry = slot && parseValue(match[3].slice(match[3].indexOf("{")))
if (!entry) continue
slots[slot.id] ??= {}
slots[slot.id][match[1]] = entry
count++
}
return count ? { state: { enabled: true, disabledGroups: {}, slots }, count } : null
}
function parseValue(rule) {
const alphaToken = /rgb\(from var\(--([\w-]+)\) r g b \/ ([\d.]+)\)/.exec(rule)
if (alphaToken && tokenMap.has(alphaToken[1])) return { kind: "token", token: alphaToken[1], alpha: Number(alphaToken[2]) * 100 }
const token = /var\(--(v2-[\w-]+)\)/.exec(rule)
if (token && tokenMap.has(token[1])) return { kind: "token", token: token[1], alpha: 100 }
const hex = /#([0-9a-f]{6})([0-9a-f]{2})?\b/i.exec(rule)
if (hex) return { kind: "color", hex: `#${hex[1].toLowerCase()}`, alpha: hex[2] ? Math.round((parseInt(hex[2], 16) / 255) * 1000) / 10 : 100 }
return null
}
$("#paste").onclick = () => {
$("#paste-text").value = ""
$("#paste-result").textContent = ""
$("#paste-result").removeAttribute("data-error")
$("#paste-dialog").showModal()
$("#paste-text").focus()
}
$("#paste-apply").onclick = (event) => {
let parsed = null
try {
parsed = parseShared($("#paste-text").value)
} catch {}
if (!parsed) {
event.preventDefault()
$("#paste-result").textContent = "No tuner rules found in that text."
$("#paste-result").setAttribute("data-error", "")
return
}
state = { ...parsed.state, name: state.name, readonly: state.readonly }
render()
changed(true)
status(`Applied ${parsed.count} pasted override${parsed.count === 1 ? "" : "s"}`)
}
$("#copy").onclick = async () => {
const css = shareableCSS()
if (!css) return status("Nothing to copy — no active overrides")
const result = await fetch("/api/copy", { method: "POST", body: css }).then((r) => r.json()).catch(() => null)
if (result?.ok) return status(`Copied ${css.split("\n").filter((line) => /^\/\* (light|dark):/.test(line)).length} rules`)
const copied = await navigator.clipboard.writeText(css).then(() => true, () => false)
status(copied ? "CSS copied" : "Copy failed — select the CSS at the bottom manually")
}
let current = { id: "reference", readonly: true }
async function loadSets(selected) {
const data = await fetch("/api/sets").then((r) => r.json())
const id = selected ?? data.active
$("#set").innerHTML = ""
for (const set of data.sets) {
const option = document.createElement("option")
option.value = set.id
option.textContent = set.readonly ? `${set.name} (read-only)` : set.name
$("#set").append(option)
}
const match = data.sets.find((set) => set.id === id) ?? data.sets[0]
current = { id: match.id, readonly: match.readonly }
$("#set").value = current.id
$("#set-rename").disabled = current.readonly
$("#set-delete").disabled = current.readonly
return current.id
}
async function switchSet(id) {
clearTimeout(saveTimer)
await loadSets(id)
state = await fetch(`/api/sets/${current.id}`).then((r) => r.json())
history.undo = []
history.redo = []
history.committed = JSON.stringify(state)
syncHistory()
render()
$("#set-note").textContent = current.readonly ? "Edits to the reference are saved as a new set." : ""
const result = await fetch("/api/active", { method: "POST", body: JSON.stringify({ id: current.id, css: buildCSS() }) }).then((r) => r.json())
status(`Showing “${state.name}” · live in ${result.live} diff view${result.live === 1 ? "" : "s"}`)
}
// window.prompt isn't available in Electron, so names come from an in-page dialog.
function askName(title, value) {
return new Promise((resolve) => {
const dialog = $("#name-dialog")
$("#name-title").textContent = title
$("#name-input").value = value
dialog.onclose = () => resolve(dialog.returnValue === "ok" ? $("#name-input").value.trim() || null : null)
dialog.returnValue = ""
$("#name-input").onkeydown = (event) => {
if (event.key !== "Enter") return
event.preventDefault()
dialog.close("ok")
}
dialog.showModal()
$("#name-input").select()
})
}
async function createSet(name, set) {
const created = await fetch("/api/sets", { method: "POST", body: JSON.stringify({ name, set }) }).then((r) => r.json())
await switchSet(created.id)
}
$("#set").onchange = () => switchSet($("#set").value)
$("#set-new").onclick = async () => {
const name = await askName("New override set", "My overrides")
if (name) await createSet(name, { enabled: true, disabledGroups: {}, slots: {} })
}
$("#set-duplicate").onclick = async () => {
const name = await askName("Duplicate set", `${state.name} copy`)
if (name) await createSet(name, state)
}
$("#set-rename").onclick = async () => {
const name = await askName("Rename set", state.name)
if (!name) return
state.name = name
await save()
await loadSets(current.id)
}
$("#set-delete").onclick = async () => {
if (!confirm(`Delete “${state.name}”?`)) return
await fetch(`/api/sets/${current.id}`, { method: "DELETE" })
await switchSet("reference")
}
const tokenList = await fetch("/api/tokens").then((r) => r.json())
tokens = tokenList
tokenMap = new Map(tokens.map((token) => [token.name, token]))
await switchSet()
</script>
</body>
</html>
-201
View File
@@ -1,201 +0,0 @@
// Temporary diff color tuner. Delete this directory, packages/session-ui/src/pierre/diff-color-tuning.ts,
// and the matching import/interpolation in packages/session-ui/src/pierre/index.ts when finished.
import path from "node:path"
import { resolveThemeVariantV2 } from "../../packages/ui/src/theme/v2/resolve"
const root = path.resolve(import.meta.dir, "../..")
const setsDir = path.join(import.meta.dir, "sets")
const localPath = path.join(import.meta.dir, "local.json")
const outputPath = path.join(root, "packages/session-ui/src/pierre/diff-color-tuning.ts")
const cdp = process.env.TUNER_CDP ?? "http://127.0.0.1:9222"
const port = Number(process.env.TUNER_PORT ?? 4455)
const theme = await Bun.file(path.join(root, "packages/ui/src/theme/themes/oc-2.json")).json()
// Alpha ramps are static CSS, not part of the resolved theme.
const alpha = Object.fromEntries(
[...(await Bun.file(path.join(root, "packages/ui/src/styles/tokens/colors.css")).text()).matchAll(
/--(v2-alpha-(?:dark|light)-\d+):\s*([^;]+);/g,
)].map((match) => [match[1]!, match[2]!.trim()]),
)
const tokens = buildTokens()
const server = Bun.serve({
port,
hostname: "127.0.0.1",
routes: {
"/": () => new Response(Bun.file(path.join(import.meta.dir, "index.html"))),
"/api/tokens": Response.json(tokens),
"/api/sets": {
GET: async () => Response.json({ active: await activeSet(), sets: await listSets() }),
POST: async (request) => {
const body = await request.json()
const id = await uniqueID(body.name)
await writeSet(id, { ...body.set, name: body.name, readonly: false })
return Response.json({ id })
},
},
"/api/sets/:id": {
GET: async (request) => {
const file = Bun.file(setPath(request.params.id))
return (await file.exists()) ? Response.json(await file.json()) : new Response("Not found", { status: 404 })
},
PUT: async (request) => {
const id = request.params.id
const current = await readSet(id)
if (current?.readonly) return new Response("Read-only set", { status: 403 })
const body = await request.json()
await writeSet(id, { ...body.set, name: body.set.name ?? current?.name ?? id, readonly: false })
return Response.json(await apply(id, body.css))
},
DELETE: async (request) => {
const current = await readSet(request.params.id)
if (!current || current.readonly) return new Response("Cannot delete", { status: 403 })
await Bun.file(setPath(request.params.id)).delete()
return Response.json({ ok: true })
},
},
"/api/active": {
POST: async (request) => {
const body = await request.json()
return Response.json(await apply(body.id, body.css))
},
},
// The embedded browser pane may deny navigator.clipboard, so copy through the OS clipboard instead.
"/api/copy": {
POST: async (request) => {
const process = Bun.spawn(["pbcopy"], { stdin: new Blob([await request.text()]) })
return Response.json({ ok: (await process.exited) === 0 })
},
},
"/api/live": {
POST: async (request) => Response.json({ live: await push((await request.json()).css) }),
},
},
})
console.log(`Diff color tuner: http://127.0.0.1:${server.port}`)
type OverrideSet = { name: string; readonly?: boolean; enabled: boolean; disabledGroups?: Record<string, boolean>; slots: Record<string, unknown> }
function setPath(id: string) {
return path.join(setsDir, `${id.replace(/[^\w-]/g, "")}.json`)
}
async function readSet(id: string): Promise<OverrideSet | undefined> {
const file = Bun.file(setPath(id))
return (await file.exists()) ? file.json() : undefined
}
function writeSet(id: string, set: OverrideSet) {
return Bun.write(setPath(id), JSON.stringify(set, null, 2) + "\n")
}
async function listSets() {
const ids = [...new Bun.Glob("*.json").scanSync(setsDir)].map((file) => file.slice(0, -5))
const sets = await Promise.all(ids.map(async (id) => ({ id, ...(await readSet(id))! })))
return sets
.map((set) => ({ id: set.id, name: set.name, readonly: Boolean(set.readonly) }))
.sort((a, b) => Number(b.readonly) - Number(a.readonly) || a.name.localeCompare(b.name))
}
async function activeSet() {
const file = Bun.file(localPath)
const active = (await file.exists()) ? (await file.json()).active : undefined
return active && (await readSet(active)) ? active : "reference"
}
async function uniqueID(name: string) {
const base = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "set"
const taken = new Set((await listSets()).map((set) => set.id))
return Array.from({ length: 100 }, (_, i) => (i ? `${base}-${i + 1}` : base)).find((id) => !taken.has(id))!
}
// Activating a set always rewrites the tuning module and pushes live CSS, so the dev app shows exactly the selected set.
async function apply(id: string, css: string) {
await Bun.write(localPath, JSON.stringify({ active: id }, null, 2) + "\n")
await Bun.write(outputPath, renderModule(css))
return { live: await push(css) }
}
function renderModule(css: string) {
const escaped = css.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${")
return `// Temporary diff color tuning output. Generated by diff-color-review-artifacts/tuner; delete with the tuner.\nexport const diffColorTuningCSS = \`${escaped}\`\n`
}
function buildTokens() {
const light = { ...alpha, ...resolveThemeVariantV2(theme.light, false) }
const dark = { ...alpha, ...resolveThemeVariantV2(theme.dark, true) }
const resolve = (map: Record<string, string>, value: string, depth = 0): string => {
const match = /^var\(--([\w-]+)\)$/.exec(value.trim())
if (!match || depth > 8) return value
return map[match[1]!] ? resolve(map, map[match[1]!]!, depth + 1) : value
}
return Object.keys(light)
.filter((name) => !name.includes("elevation") && !name.includes("font"))
.map((name) => ({ name, light: resolve(light, light[name]!), dark: resolve(dark, dark[name] ?? light[name]!) }))
.filter((token) => /^(#|rgb|hsl|oklch)/.test(token.light))
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))
}
// Live preview: Pierre reads unsafeCSS when a viewer mounts, so already-open diffs need the CSS pushed into their shadow roots.
async function push(css: string) {
const targets: { type: string; webSocketDebuggerUrl: string }[] = await fetch(`${cdp}/json/list`)
.then((response) => response.json())
.catch(() => [])
const counts = await Promise.all(
targets.filter((target) => target.type === "page").map((target) => evaluate(target.webSocketDebuggerUrl, injector(css))),
)
return counts.reduce<number>((sum, count) => sum + (typeof count === "number" ? count : 0), 0)
}
function injector(css: string) {
return `(() => {
window.__diffColorTuning = ${JSON.stringify(css)}
const apply = (host) => {
const root = host.shadowRoot
if (!root) return
let style = root.querySelector("style[data-diff-color-tuning]")
if (!style) {
style = document.createElement("style")
style.dataset.diffColorTuning = ""
root.appendChild(style)
}
if (style.textContent !== window.__diffColorTuning) style.textContent = window.__diffColorTuning
}
const all = () => document.querySelectorAll("diffs-container").forEach(apply)
all()
if (!window.__diffColorTuningObserver) {
let queued = false
window.__diffColorTuningObserver = new MutationObserver(() => {
if (queued) return
queued = true
requestAnimationFrame(() => { queued = false; all() })
})
window.__diffColorTuningObserver.observe(document.body, { childList: true, subtree: true })
}
return document.querySelectorAll("diffs-container").length
})()`
}
function evaluate(url: string, expression: string) {
return new Promise<unknown>((resolve) => {
const socket = new WebSocket(url)
const timer = setTimeout(() => {
socket.close()
resolve(undefined)
}, 2000)
socket.onopen = () =>
socket.send(JSON.stringify({ id: 1, method: "Runtime.evaluate", params: { expression, returnByValue: true } }))
socket.onmessage = (event) => {
const message = JSON.parse(String(event.data))
if (message.id !== 1) return
clearTimeout(timer)
socket.close()
resolve(message.result?.result?.value)
}
socket.onerror = () => {
clearTimeout(timer)
resolve(undefined)
}
})
}
@@ -1,3 +0,0 @@
*
!.gitignore
!reference.json
@@ -1,176 +0,0 @@
{
"name": "Reference",
"readonly": true,
"enabled": true,
"disabledGroups": {},
"slots": {
"add-row": {
"light": {
"kind": "token",
"token": "v2-green-600",
"alpha": 5
},
"dark": {
"kind": "token",
"token": "v2-green-800",
"alpha": 10
}
},
"add-gutter": {
"light": {
"kind": "token",
"token": "v2-green-600",
"alpha": 5
},
"dark": {
"kind": "token",
"token": "v2-green-800",
"alpha": 10
}
},
"add-inline": {
"light": {
"kind": "token",
"token": "v2-green-600",
"alpha": 20
},
"dark": {
"kind": "token",
"token": "v2-green-600",
"alpha": 40
}
},
"add-bar": {
"light": {
"kind": "token",
"token": "v2-green-900",
"alpha": 100
},
"dark": {
"kind": "token",
"token": "v2-green-400",
"alpha": 100
}
},
"add-number": {
"light": {
"kind": "token",
"token": "v2-green-900",
"alpha": 100
},
"dark": {
"kind": "token",
"token": "v2-green-400",
"alpha": 100
}
},
"del-row": {
"light": {
"kind": "token",
"token": "v2-red-600",
"alpha": 5
},
"dark": {
"kind": "token",
"token": "v2-red-600",
"alpha": 10
}
},
"del-gutter": {
"light": {
"kind": "token",
"token": "v2-red-600",
"alpha": 5
},
"dark": {
"kind": "token",
"token": "v2-red-600",
"alpha": 10
}
},
"del-inline": {
"light": {
"kind": "token",
"token": "v2-red-600",
"alpha": 20
},
"dark": {
"kind": "token",
"token": "v2-red-600",
"alpha": 40
}
},
"del-bar": {
"light": {
"kind": "token",
"token": "v2-red-800",
"alpha": 100
},
"dark": {
"kind": "token",
"token": "v2-red-500",
"alpha": 100
}
},
"del-number": {
"light": {
"kind": "token",
"token": "v2-red-800",
"alpha": 100
},
"dark": {
"kind": "token",
"token": "v2-red-500",
"alpha": 100
}
},
"number": {
"light": {
"kind": "token",
"token": "v2-text-text-faint",
"alpha": 100
},
"dark": {
"kind": "token",
"token": "v2-text-text-faint",
"alpha": 100
}
},
"fold-text": {
"light": {
"kind": "token",
"token": "v2-text-text-faint",
"alpha": 100
},
"dark": {
"kind": "token",
"token": "v2-text-text-faint",
"alpha": 100
}
},
"add-inline-text": {
"light": {
"kind": "token",
"token": "v2-green-1200",
"alpha": 100
},
"dark": {
"kind": "token",
"token": "v2-text-text-base",
"alpha": 100
}
},
"del-inline-text": {
"light": {
"kind": "token",
"token": "v2-red-1200",
"alpha": 100
},
"dark": {
"kind": "token",
"token": "v2-text-text-base",
"alpha": 100
}
}
}
}
+685
View File
@@ -0,0 +1,685 @@
# Service Lifecycle: Election, Restart, and Reconnect
Status: in progress
Incident: [#36688](https://github.com/anomalyco/opencode/issues/36688)
## Summary
The managed V2 service keeps its current update policy: the background updater
may install a new package, but only a freshly launched TUI activates that update
after finding an older running service. Existing TUIs never replace a service;
they only reconnect.
The restart path changes in three places:
1. A process-held OS lock, not the HTTP port or registration file, elects
exactly one server owner for its lifetime.
2. The elected process binds and registers a minimal lifecycle surface before
it initializes the application, so clients can distinguish a slow winner
from an absent server.
3. TUIs rediscover and reconnect indefinitely. Transport loss is never a
terminal error by itself.
Several clients may spawn small contenders during a restart. This is safe and
intentional: one contender acquires the lock and initializes, while every loser
exits before expensive server boot. The design does not require clients to
agree on a single initiator.
This proposal does not introduce a supervisor process, warm candidate server,
protocol negotiation, idle background restart, or general execution-recovery
framework.
## Architecture at a Glance
```text
╭───────────────────╮
│ CLI ServiceConfig │
╰─────────┬─────────╯
│
▼
╭──────────────────────╮
│ CLI ServerConnection │
╰───────────┬──────────╯
╭──────────────────╰───────────────────╮
▼ ▼
╭──────────────────────────╮ ╭─────────────────────────╮
│ Client Service lifecycle │ │ CLI runPromiseWith seam │
╰─────────────┬────────────╯ ╰─────────────┬───────────╯
╰─────╮ │
▼ ▼
╭────────────────────────────╮ ╭─────────────╮
│ Background service process │ │ TUI / Solid │
╰──────────────┬─────────────╯ ╰──────┬──────╯
│ │
╰────────────◀────────────────────╯
╭───────────────────────╮
│ Server HTTP transport │
╰───────────┬───────────╯
│
▼
╭──────────────────╮
│ Core application │
╰──────────────────╯
```
| Owner | Responsibility |
| ------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
| `packages/client/src/effect/service.ts` | Effect-native discovery, start, and stop lifecycle operations |
| `packages/cli/src/services/service-config.ts` | CLI registration path, installed version, and daemon command |
| `packages/cli/src/services/server-connection.ts` | Resolve an endpoint and, only for the shared service, grouped reconnect and restart Effects |
| `packages/cli/src/server-process.ts` | Daemon election, registration, and server process boot |
| `packages/server/src/process.ts` | HTTP lifecycle shell and application transport |
| `packages/core` | Application behavior behind the transport |
| CLI default handler | Convert lifecycle Effects with the outer `FileSystem` context and pass grouped Promise capabilities |
| `packages/tui` Solid client context | Own event-stream reconnect, endpoint replacement, status, and user-triggered restart UI |
## Implementation Status
| Area | State |
| ------------------------- | --------------------------------------------------------------------- |
| Lifetime ownership | Implemented on this branch with a scoped OS lock |
| Contender behavior | Implemented; losers exit before the server module is imported |
| Registration repair | Implemented; the owner reasserts deleted or corrupt discovery |
| Channel isolation | Implemented with no-clobber migration for legacy preview discovery |
| Client startup waiting | Implemented; slow winners are not killed and waiting is indefinite |
| Lifecycle shell | Implemented; the owner binds and registers before application boot |
| Failed-state latching | Implemented; deterministic boot failure stays bound and actionable |
| Recovery diagnostics | Implemented; the TUI shows status instead of transport internals |
| Cross-platform validation | macOS runtime verified; Linux and Windows run in the unit-test matrix |
## Context
The V2 CLI runs a shared managed service that owns Sessions, location graphs,
plugins, permissions, and tool execution. The service updater can replace the
installed package while the current process continues running the old image.
A later TUI launch then detects the version mismatch and replaces the service.
Incident #36688 showed four failures in that replacement path:
- Multiple TUIs spawned heavyweight server contenders.
- A winner remained unobservable while it cold-booted, so another wave treated
it as absent and displaced it.
- A fresh TUI exhausted its reconnect budget and crashed with an unhandled
transport defect.
- A losing contender remained alive and consumed about 1 GB of RSS.
The `origin/v2` baseline serializes service startup with `EffectFlock`. A
contender acquires a three-second heartbeat lease, checks whether another
service became discoverable, and only the winner crosses the application-boot
boundary. This already prevents simultaneous heavy boots and makes startup
losers exit.
The lease is released immediately after registration, however, so it is not
lifetime ownership. Registration then reverts to last-writer-wins authority: a
deleted or corrupt registration can admit a second boot, a displaced server
terminates itself through its 10-second registration self-check, and a stalled
lease holder can be displaced after the three-second service staleness timeout.
`Flock` and `EffectFlock` live in `packages/core/src/util` and are also used for
config writes, MCP auth, npm installs, and repository caching. Despite the
name, the primitive is an atomic-mkdir lease with heartbeat and staleness
takeover, not an OS-held lock. It remains appropriate for bounded critical
sections, including today's startup fence, but is not lifetime service
ownership.
The current implementation also mixes three different concepts:
- **Ownership:** which process is allowed to be the managed server.
- **Discovery:** where clients can reach that process.
- **Lifecycle:** whether that process is starting, ready, stopping, or failed.
This design gives each concept one authority.
```definitions
[
{
"term": "Owner",
"definition": "The one process holding the process-held OS service lock."
},
{
"term": "Contender",
"definition": "A small serve process attempting to acquire the service lock. It must not initialize the application before winning."
},
{
"term": "Registration",
"definition": "An atomic discovery record containing the elected owner's identity and endpoint. Registration never grants ownership."
},
{
"term": "Lifecycle shell",
"definition": "The minimal HTTP surface bound by the elected process before application initialization. It serves health and retryable startup responses."
},
{
"term": "Application",
"definition": "The full server routes and global or location-scoped modules used for normal OpenCode work."
}
]
```
## Goals
- At most one process initializes and serves the managed application.
- Losing contenders exit before database, route, plugin, MCP, or location boot.
- A slow winner becomes observable before expensive initialization.
- Existing and freshly launched TUIs survive retryable service unavailability.
- Reconnect follows service state instead of displaying retry counts or raw
transport failures.
- Version-mismatch replacement remains triggered by a fresh TUI launch.
- A stale or malformed registration cannot create a second owner.
- An unresponsive owner is never killed automatically by an arbitrary TUI.
- Every spawned contender has a bounded path to ownership or exit.
## Non-goals
- Restarting automatically when a background update finds an idle window.
- Running old and candidate application servers concurrently.
- Adding a permanent steward, proxy, or supervisor process.
- Zero-downtime worker handoff or automatic rollback.
- Application protocol negotiation or automatic TUI self-restart.
- General hard-crash recovery for active Sessions.
- Defining recovery semantics for provider attempts, tools, shells, sub-agents,
permissions, questions, or background jobs.
- Automatically killing a frozen owner.
- Bounding concurrent location cold boots after clients reconnect.
- Multi-machine or clustered service placement.
## Invariants
1. **The service lock is ownership.** Exactly one process may hold the OS lock
for one installation channel and service profile.
2. **Ownership precedes boot.** A contender performs no expensive application
initialization before it acquires the lock.
3. **Ownership lasts for the process lifetime.** The owner holds an open lock
handle until the managed server exits. The OS releases it on process death
without a cleanup callback.
4. **The port is transport, not election.** The owner may select a dynamic port
after acquiring the lock.
5. **Registration is discovery, not election.** Deleting, corrupting, or
replacing registration does not invalidate a live owner's lock.
6. **Only a fresh launch enforces package version.** Existing TUIs reconnect to
the current owner without initiating version replacement.
7. **Transport loss is retryable.** It never terminates a TUI without a separate
diagnosed, non-retryable cause.
8. **Clients do not kill an unresponsive owner automatically.** Destructive
recovery requires the explicit `service restart` command.
9. **Lifecycle does not promise execution semantics.** Graceful replacement
invokes Session suspension and resumption hooks, but tool-level continuity
belongs to a separate design.
## System Model
```text
╭───────────────────────╮ ╭──────────────────────────────╮
│ Fresh or existing TUI │ │ Process-held OS service lock │
╰───────────┬───────────╯ ╰───────────────┬──────────────╯
╰─────┬ normal requests observe ───────────────────────╮ │
│ discover │ ├──╯ authorizes one owner
▼ │ ▼
╭───────────────────╮ │ ╭─────────────────╮
│ Registration file │ │ │ Lifecycle shell │
╰───────────────────╯ │ ╰────────┬────────╯
│ │
├────────────────────────╯
▼
╭──────────────────────╮
│ OpenCode application │
╰──────────────────────╯
```
The lifecycle shell and application run in the same process. The distinction is
initialization order and responsibility, not process topology.
## Service Status
The server reports one small status value:
```typescript
type ServiceStatus =
| {
type: "starting"
}
| {
type: "ready"
}
| {
type: "stopping"
targetVersion?: string
}
| {
type: "failed"
message: string
action: string
}
```
The client adds only the discovery states needed by callers:
```typescript
type Status = { type: "missing" } | { type: "unreachable" } | { type: "unresponsive" } | ServiceStatus
```
The health response retains the existing fields for old clients and adds the
status discriminant:
```typescript
type ServiceHealth = {
healthy: true
version: string
pid: number
instanceID: string
status: ServiceStatus
}
```
`healthy: true` means the registered lifecycle shell is responding and its
identity matches registration. New clients use `status.type === "ready"` as
the application-readiness signal.
During `starting` or `stopping`, application requests are not held in memory.
They receive an immediate retryable response:
```http
HTTP/1.1 503 Service Unavailable
Retry-After: 1
Content-Type: application/json
{"code":"service_starting"}
```
`stopping` uses `service_stopping`. A failed application boot uses
`service_failed` and includes a safe diagnostic message.
A failed owner remains bound and keeps holding the service lock. Exiting on
failure would let every waiting client's `ensureRunning` loop elect a new
contender that repeats the same heavy failing boot, so staying bound turns a
deterministic boot failure into one observable `failed` state instead of a
client-driven respawn loop. Recovery still works: a fresh launch observes the
failed instance through the stop path, and explicit `service restart` replaces
it.
## Registration Contract
Registration contains only discovery identity:
```typescript
type ServiceRegistration = {
schema: 1
instanceID: string
version: string
url: string
pid: number
}
```
Authentication continues to use the existing private service credential
storage. The registration schema does not change that policy.
The owner writes registration only after the lifecycle shell has bound:
1. Bind the lifecycle shell.
2. Write a temporary registration file with mode `0600`.
3. Atomically rename it over the old registration.
4. Serve lifecycle health as `starting`.
On shutdown, the owner removes registration only if the current file still has
its `instanceID`. An old finalizer can never remove a successor's registration.
While running, the owner periodically asserts its registration. Because the
lock guarantees exactly one live owner, any registration that does not name the
owner is stale or corrupt, and the owner rewrites it. A deleted or clobbered
registration therefore heals within one assertion interval instead of leaving
clients waiting on absent discovery. This inverts today's self-check loop,
which terminates the displaced process instead of repairing discovery.
Legacy registration shapes are decoded by a compatibility adapter. The new
domain type does not make fields optional to represent old formats.
## Election
This design promotes today's startup fence into lifetime ownership.
Last-writer-wins registration is replaced by a process-held OS lock that is
acquired before any expensive boot work and held for the entire service
lifetime.
A heartbeat-and-staleness lease, including the existing `Flock` utility, is not
sufficient for service ownership: the service configures a three-second stale
timeout, after which its lock can be broken and recreated. An event-loop stall,
a suspended machine, or a debugger pause can therefore make a live owner appear
stale and allow a contender to displace it. Service ownership requires a
process-held OS lock: `flock` on Unix and an exclusively bound named pipe on
Windows. It cannot be broken because a heartbeat exceeded a timeout. Process
death releases the lock through the OS.
Neither Bun nor Node exposes `flock` directly, the existing `Flock` utility is
an mkdir-plus-heartbeat lease rather than an OS-held lock, and the common
lockfile packages are staleness-based leases as well. The platform layer uses
`bun:ffi` to call `flock` on POSIX and Node's named-pipe server support on
Windows, where Bun FFI is not available on every shipped architecture. It lives
alongside the existing utility in `packages/core/src/util`. This primitive is
the foundation of the design, so the delivery sequence spikes it first.
```text
Contender Lock Lifecycle Application
│ │ │ │
├─ try acquire ───▶ │ │
│ │ │ │
╭─ alt: lock held ────────────────────────────────────────────────╮
│ │ │ │ │ │
│ ◀─ busy ──────────┤ │ │ │
│ │ │ │ │ │
│ ├─────────╮ │ │ │ │
│ │ exit │ │ │ │ │
│ ◀─────────╯ │ │ │ │
│ │ │ │ │ │
├─ else: lock acquired ───────────────────────────────────────────┤
│ │ │ │ │ │
│ ◀─ owner ─────────┤ │ │ │
│ │ │ │ │ │
│ ├─ bind, register, starting ────────▶ │ │
│ │ │ │ │ │
│ ├─ initialize ──────────────────────────────────────────────▶ │
│ │ │ │ │ │
│╭─ alt: boot succeeds ──────────────────────────────────────────╮│
││ │ │ │ │ ││
││ │ │ ◀─ ready ───────────────┤ ││
││ │ │ │ │ ││
│├─ else: boot fails ────────────────────────────────────────────┤│
││ │ │ │ │ ││
││ │ │ ◀─ failed, stay bound ──┤ ││
││ │ │ │ │ ││
│╰───────────────────────────────────────────────────────────────╯│
│ │ │ │ │ │
╰─────────────────────────────────────────────────────────────────╯
│ │ │ │
```
Lock acquisition by a contender is nonblocking or tightly bounded. A loser
must exit before constructing application routes or importing startup-heavy
modules.
Several clients may spawn contenders concurrently. The design guarantees one
heavy winner, not one process spawn. If the winner crashes during startup, the
OS releases the lock and a later client retry starts another election.
The lock is scoped by installation channel and service profile. Local, preview,
and stable installations cannot displace one another.
## Update Activation
Background update behavior remains unchanged:
1. The running service checks for an update.
2. The updater installs the package in the background.
3. The running process continues using its existing process image.
4. No idle check or automatic restart occurs.
A fresh TUI launch activates the installed update:
1. Read registration and authenticate the responding service.
2. If its package version matches the fresh client, attach normally.
3. If the version differs, request graceful stop of that exact registered
instance using the existing authenticated stop path.
4. Re-check instance identity before every signal or escalation in that path.
5. Wait for the old process to exit and release the service lock.
6. Call `ensureRunning` until a compatible service becomes ready.
Concurrent fresh launchers may all observe the same old instance. Stopping that
exact instance must be idempotent. Once registration names a different instance,
a stale launcher stops signaling and returns to discovery.
No durable restart-transition record is introduced. The initiating fresh TUI
already knows the source and target versions and can display its update
preflight. Existing TUIs may display `Updating...` if they observed `stopping`;
otherwise `Waiting for background service...` is the honest fallback.
## Fresh Launch Versus Reconnect
Fresh launch and reconnect deliberately have different version policies:
```typescript
type ManagedConnection =
| {
type: "launch"
requiredVersion: string
}
| {
type: "reconnect"
}
```
- `launch` requires the installed package version and may activate replacement.
- `reconnect` accepts the current owner and never activates replacement.
This preserves today's permissive reconnect behavior. Explicit application
protocol negotiation and automatic TUI re-exec remain follow-ups.
## Client Reconnect
Fresh and existing TUIs use the same status loop after startup:
1. Read registration on every attempt. Do not retry a stale URL indefinitely.
2. If registration is absent, call `ensureRunning` and continue waiting.
3. If registration is unreachable, call `ensureRunning`. A live owner prevents
contenders from acquiring the lock; a dead owner does not.
4. If status is `starting` or `stopping`, wait.
5. If status is `failed`, show its actionable message.
6. If status is `ready`, rebuild HTTP and event-stream clients for the new
endpoint and perform authoritative state reconciliation.
Retry cadence is internal policy. Retry counts are telemetry, not user-facing
state. The TUI waits until the service is ready or the user exits.
Transport failures are handled at the TUI run boundary. A raw client transport
error or Effect defect must not escape to the terminal. Hard exit is reserved
for diagnosed causes such as invalid local configuration, failed authentication,
or a foreign process occupying an explicitly configured port.
The UI derives text from status:
| Status | User-facing state |
| ------------------------ | ----------------------------------- |
| No registration | `Starting background service...` |
| Registration unreachable | `Waiting for background service...` |
| `starting` | `Starting OpenCode vX...` |
| `stopping` | `Updating to vX...` |
| `failed` | Actionable failure message |
| `ready` | Normal TUI |
## Graceful Session Continuity
Version-mismatch replacement uses the existing graceful Session suspension and
resumption hooks:
1. The old server snapshots active Session IDs during graceful teardown.
2. The successor schedules those Sessions for continuation.
3. The runner reloads durable Session history before continuing.
This lifecycle design does not define what an interrupted physical provider
attempt or tool invocation means. It does not promise that external side effects
did not occur, replay the exact interrupted tool, preserve an in-memory form, or
recover process-local background work.
Those concerns require a separate execution-continuity design covering tools,
shells, sub-agents, permissions, questions, provider attempts, and hard-crash
recovery.
## Unresponsive Owner
An unreachable registration does not prove that the owner is dead. A contender
attempts the service lock:
- If the lock is free, the contender starts a replacement.
- If the lock is held, the contender exits and the client keeps waiting.
After a bounded diagnostic threshold, the client may show:
```text
The background service owns the service lock but is not responding.
Run `opencode service restart` to recover it.
```
Only explicit `service restart` may perform destructive recovery. It verifies
the complete registration and process instance before signaling, waits for
graceful exit, re-checks identity before escalation, and refuses to kill a
process it cannot positively identify.
Automatic frozen-owner recovery is deferred.
## Failure Walkthroughs
### Update with open TUIs
1. The old service installs vNext but keeps running.
2. A fresh vNext TUI finds the healthy vOld service and requests graceful stop.
3. The old service reports `stopping`, suspends active Sessions, and exits.
4. Open TUIs enter their indefinite status loops.
5. One or more clients spawn contenders.
6. One contender acquires the service lock. Losers exit before heavy boot.
7. The winner binds and registers the lifecycle shell as `starting`.
8. Clients stop spawning and wait on the observable winner.
9. The winner initializes the application and reports `ready`.
10. TUIs rebuild clients, reconcile state, and resume.
### Server crashes while ready
1. The endpoint becomes unreachable and registration may remain stale.
2. Clients call `ensureRunning`.
3. Process death has released the service lock.
4. One contender wins, replaces registration, and starts normally.
5. Detailed active-execution recovery is outside this design.
### Winner crashes during startup
1. Clients observed `starting` and remain alive.
2. Process death releases the service lock.
3. A later reconnect attempt starts another election.
4. One new contender wins; all other contenders exit.
### Registration is deleted while the owner is healthy
1. Clients may call `ensureRunning` because discovery is absent.
2. Every contender fails to acquire the owner's lock and exits.
3. No second application initializes.
4. The owner's next registration assertion republishes discovery.
### Owner is alive but unresponsive
1. Health fails, but the process still holds the service lock.
2. Contenders fail lock acquisition and exit.
3. Clients wait and eventually show explicit recovery guidance.
4. No TUI kills the owner automatically.
## TDD Verification
Implementation should proceed test-first with real subprocesses and real locks.
Mocks cannot establish process death, lock release, loser cleanup, or port
behavior.
### Election tests
| Scenario | Required result |
| ----------------------------------------------------- | ------------------------------------------------------- |
| Ten contenders start simultaneously | Exactly one crosses the application-boot boundary |
| Winner pauses after lock acquisition | No loser initializes or remains alive |
| Winner event loop pauses beyond the old stale timeout | Ownership is not displaced |
| Winner crashes before bind | Lock releases; a later attempt wins |
| Winner crashes after bind but before registration | Lock releases; a later attempt replaces stale discovery |
| Registration is deleted while owner runs | No second owner initializes |
| Registration is malformed | Lock still prevents a second owner |
| Registration names a dead PID | New contender can acquire the released lock |
| Two installation channels start | Each elects an independent owner |
| Explicit configured port is foreign-owned | Fail diagnostically; do not kill the foreign process |
The fixture records a marker immediately before application initialization. The
tests assert that only one process writes that marker and that every loser exits
within a bounded interval. The harness should also assert that a loser's peak
RSS stays an order of magnitude below an application boot, since import weight
was the observed incident cost.
### Lifecycle tests
| Scenario | Required result |
| ----------------------------------------------- | ---------------------------------------------------------------- |
| Winner owns lock but application boot is paused | Health reports `starting` |
| Application request arrives during startup | Immediate retryable `503` |
| Application becomes ready | Status changes once from `starting` to `ready` |
| Graceful replacement begins | Status reports `stopping` before disconnect |
| Application initialization fails | Actionable `failed` status; owner stays bound and holds the lock |
| Registration is deleted while owner runs | Owner republishes it within one assertion interval |
| Owner exits | Registration is removed only if it still names that owner |
### Update tests
| Scenario | Required result |
| -------------------------------------- | -------------------------------------------------------- |
| Background update installs vNext | Running vOld service does not restart |
| Fresh vNext launch finds vOld | Exact old instance stops; vNext eventually becomes ready |
| Two fresh vNext launches race | One heavy successor; both clients attach |
| Existing vOld TUI reconnects to vNext | It never requests replacement |
| Stale launcher observes a new instance | It does not signal the new instance |
### Reconnect tests
| Scenario | Required result |
| --------------------------------------------------- | -------------------------------------------------- |
| Endpoint disappears and changes port | TUI rediscovers and rebuilds clients |
| Service remains unavailable beyond old retry budget | TUI remains alive |
| Event stream reconnects | Client performs authoritative state reconciliation |
| Transport returns an unexpected defect | TUI formats it; no raw stack escapes |
| Owner remains unresponsive | TUI waits and shows explicit restart guidance |
## Delivery Sequence
1. **Spike the lock primitive.** Prove a nonblocking, process-held OS lock
under Bun on macOS, Linux, and Windows (`bun:ffi` to `flock` on POSIX and a
named pipe on Windows), including release on hard kill and behavior across
containers and network filesystems used in CI.
2. **Expand the subprocess test harness.** Begin from the baseline
two-contender test and cover ten contenders, lock release on crash, a paused
winner, deleted or corrupt registration, and bounded loser exit before
changing ownership.
3. **Contain client failure.** Make transport loss nonterminal, rediscover on
every cycle, and format unexpected failures at the TUI boundary.
4. **Promote the startup fence to process-held ownership.** Preserve the
existing pre-boot acquisition seam, replace its lease with the OS lock, hold
it until process exit, and invert the registration self-check from
self-termination to reassertion.
5. **Bind the lifecycle shell first.** Publish registration and `starting`,
return retryable `503` for application requests, then initialize the app.
The health contract change is public API: regenerate clients from
`packages/client` with `bun run generate`.
6. **Codify launch versus reconnect.** Fresh launch enforces installed version;
reconnect never activates replacement.
7. **Integrate graceful replacement.** Preserve current background-install and
fresh-launch activation behavior while invoking Session continuity hooks.
8. **Harden explicit recovery.** Verify exact process identity during explicit
`service restart`; never automatically kill an unresponsive owner.
9. **Run the full multi-process suite.** Include repeated restart cycles and
assert that no contender or child process remains afterward.
## Acceptance Criteria
- Ten concurrent restart observers produce one application initialization.
- No losing contender survives or builds a location graph.
- A 30-second application boot remains continuously observable as `starting`.
- A TUI remains alive through a service outage longer than the previous retry
budget.
- A service endpoint change does not require restarting an existing TUI.
- Background installation alone does not restart the service.
- A fresh mismatched TUI eventually attaches to the installed service version.
- Existing reconnecting TUIs never replace the current owner.
- Registration corruption cannot produce two owners.
- A deleted registration heals without restarting the owner or any client.
- An unresponsive owner is not killed without an explicit recovery command.
- Raw transport defects never escape to the terminal.
## Follow-ups
- Idle background update activation with an admission fence.
- Application protocol compatibility and automatic local TUI re-exec.
- Durable execution recovery for provider attempts and tools.
- Shell, sub-agent, permission, question, and background-job continuity.
- Automatic recovery for a positively identified frozen owner.
- Cold-boot concurrency limits and interaction-prioritized location loading.
- A steward or socket-handoff architecture if zero-downtime replacement becomes
a real requirement.
+298
View File
@@ -0,0 +1,298 @@
# V1 to V2 Database Migration
## Approach
- Use the `dev` branch database schema and migration registry as the V1 baseline.
- Remove migrations that exist only on the V2 branch.
- Generate one canonical migration from the `dev` schema to the final V2 schema.
- Keep the canonical migration focused on schema changes and dropping obsolete tables.
- Run the V1 history backfill through an experimental server endpoint invoked by the CLI before it opens the TUI.
- Show committed session progress while the endpoint runs.
Expose `GET /api/experimental/migration/v1` for status and a blocking `POST /api/experimental/migration/v1` to run or
resume the backfill. The status is `required`, `running`, or `completed`. On startup, the CLI checks status first and
renders no migration UI when it is already complete. For required or running status, it shows a spinner and waits for the
blocking POST without a request timeout. While migration runs, poll GET once per second and render completed and total
session counts. GET derives total from all session rows and completed from rows through the stored cursor; the count
advances only after a session transaction commits. The POST returns `{ status: "completed" }`. Do not add a background
job or streaming progress protocol. Interrupted calls resume from the stored cursor.
Initially, only interactive TUI startup performs this check; noninteractive run, ACP, raw API, service, health, version,
and help flows do not trigger the backfill.
Keep migration behavior in Core: status, semaphore, checkpointing, V1 decoding, transformation, and database writes.
Protocol owns the experimental GET/POST contracts, Server handlers delegate to Core, and the interactive CLI owns only
the status check and spinner presentation.
Guard the endpoint with one process-local Effect `Semaphore`. Concurrent callers wait; after the active call completes,
waiting callers acquire the permit, observe the completion key, and return immediately. No distributed lock is required
for the current single elected server process.
## Preserve
The canonical V1 data remains in its existing tables. In particular, preserve `session`, `message`, and `part` rows.
Preserve `workspace` rows and existing `session.workspace_id` values unchanged. The migration must not clear or rebuild
workspace relationships.
Preserve existing non-null `session.agent` and `session.model` selections. Fill missing values from the latest ordinary
V1 user message ordered by `time_created` and `id`, excluding compaction and subtask-only messages. Copy agent, provider
ID, model ID, and variant, normalizing an absent variant to `default`.
Recompute session usage aggregates from all canonical V1 assistant messages, including compaction or other internal
assistants omitted from the V2 projection. Overwrite session cost and input, output, reasoning, cache-read, and
cache-write token totals with those sums.
Clear persisted `session.revert` state. A staged revert is transient operational state and may refer to omitted projection
rows or unavailable snapshots; it must not resume automatically after upgrading. Preserve the underlying messages,
parts, and file history.
Clear `session.time_compacting`, leave the new `time_suspended` column as `NULL`, and preserve session creation, update,
and archive timestamps. Preserve project `time_initialized`; it is unrelated durable state.
Keep the legacy `todo` table and its data physically unchanged, but do not include it in the final V2 Drizzle schema.
After generation, remove the generated `DROP TABLE todo` statement from the canonical migration so the table remains as
unmanaged legacy storage.
## Per-Session Replacement
Do not truncate `event`, `event_sequence`, or `session_message` globally before the backfill. A whole-table delete can
hold SQLite's writer lock long enough to block the running TUI.
Replace each legacy session's V2 state inside that session's checkpointed migration transaction. Delete `event` rows for
the session aggregate, delete its `session_message` rows, rebuild its projection from canonical V1 `message` and `part`
rows, and overwrite its `event_sequence` watermark. If migration of that session fails, all replacements roll back and
the durable cursor remains at the previously committed session. Rows owned by sessions outside the legacy migration set
remain untouched.
## Message Backfill
Backfill canonical V1 history from `message` and `part` into `session_message`. This is the main data transformation in
the migration. Preserving the V1 tables alone keeps the data safe but does not make existing history visible through the
V2 session APIs, which read `session_message`.
Do not fail the whole migration when a V1 message or part payload cannot be decoded. Skip an undecodable message's V2
projection and log its session and message IDs. Skip an undecodable part while continuing to map its message, and perform
special-message pairing only with decoded rows. Assign sequences after filtering. Leave every malformed source row
untouched in the V1 tables.
Skip and log orphan parts whose source message does not exist and parts with unknown or unsupported types. Continue
migrating the owning message and other valid parts. Include session, message, part ID, and observed type in warnings, and
leave skipped source rows unchanged.
Reuse each V1 `message.id` as the corresponding `session_message.id`. Stable IDs keep the migration deterministic and
avoid rewriting other persisted state that may refer to a message.
For ordinary user and assistant rows, preserve source `message.time_created` and `message.time_updated`. Entirely
synthetic messages preserve their source timestamps, and synthetic rows split from mixed messages use the source user
timestamps. A collapsed compaction uses the compaction user creation time and the later update time of the compaction
user and summary assistant. Keep payload creation/completion times consistent with row timestamps.
Within each session, order V1 messages by `time_created` and then `id`, matching the existing V1 message index. Assign
contiguous `session_message.seq` values starting at `0`.
Map ordinary V1 messages one-to-one by role. Each ordinary V1 user message becomes one V2 `user` row, and each ordinary
V1 assistant message becomes one V2 `assistant` row. Fold the source message's ordered V1 parts into that row's V2
payload.
Keep ordinary messages even when their transformed payload becomes empty after filtering. Preserve an empty V2 user row
with `text: ""` and an empty V2 assistant row with `content: []` so IDs, chronology, and conversation structure remain
stable. Omit only explicitly dropped internal concepts and undecodable messages.
Handle semantic marker parts before applying the ordinary mapping. In particular, a V1 user message containing a
`compaction` part and its paired assistant summary represent one compaction operation, not two ordinary messages. Special
part mappings must be decided explicitly before implementing the backfill.
Do not carry the V1 subtask concept into the V2 projection. Omit user messages containing only `subtask` parts and omit
the paired assistant task-tool messages generated from those markers. For mixed user messages, ignore the `subtask`
parts while preserving ordinary content, and still omit assistant task-tool messages generated by the skipped subtasks.
Keep all source rows unchanged in the V1 `message` and `part` tables.
Map ordinary V1 assistant `text` and `reasoning` parts into the V2 assistant `content` array in part order. Preserve text,
including empty assistant text parts used as structural separators. Map V1 part metadata to optional V2 provider state.
For reasoning, map `time.start` to `time.created` and optional `time.end` to `time.completed`.
Preserve V1 tool parts that are `pending` or `running`, but convert them to terminal V2 tool error states. Preserve the
call ID, tool name, parsed input, metadata, and available start time. Use the assistant message creation time when the V1
state has no start time. Set the error to type `tool.interrupted` with message
`Tool execution was interrupted before V2 migration`. Never resume migrated tool executions.
For a completed V1 tool part, use `callID` as the V2 tool content ID and preserve the tool name and parsed input. Set the
state to `completed`. Convert V1 output into the first text content item and convert stored output attachments into
following file content items with their URI, MIME type, and filename. Preserve state metadata. Map `time.start` to
`time.created` and `time.end` to `time.completed`. When `time.compacted` exists, use
`[Old tool result content cleared]` as the only output and omit attachments.
For a failed V1 tool part, preserve the call ID, tool name, parsed input, metadata, and timestamps, and set the V2 state
to `error`. Convert the V1 error string to a structured error with type `tool.execution`. If V1 metadata contains a string
`output`, preserve it as optional V2 text content. Map `time.start` to `time.created` and `time.end` to `time.completed`.
For an ordinary V1 assistant message, preserve agent, provider ID, model ID, optional variant, creation and completion
times, cost, and input/output/reasoning/cache token counts. Use `default` when the V1 variant is absent. Ignore V1
`tokens.total` because it is derivable and V2 does not persist it.
Use V1 assistant `parentID` only while pairing compactions and skipped subtasks with their originating user messages. Do
not persist it in ordinary V2 assistant rows; V2 uses ordered history rather than user/assistant parent links.
Ignore the optional V1 assistant `structured` output value. V2 has no equivalent top-level assistant field, and visible
text and tool content are migrated separately. Retain the original structured value only in the V1 `message` row.
Ignore V1 assistant `mode` and historical `path` (`cwd` and `root`). Mode is redundant with the preserved assistant
agent, and historical filesystem paths do not belong to the V2 assistant message contract. Retain them only in the V1
`message` row.
For assistant finish reasons, preserve `stop`, `length`, `tool-calls`, `content-filter`, `error`, and `unknown`. Map every
other nonempty V1 finish value to `unknown`, and leave the field absent when V1 omitted it. Do not retain unrecognized raw
finish values in metadata.
Map V1 assistant errors into the current V2 `{ type, message }` storage shape. Normalize Auth, content-filter, context
overflow, structured-output, output-length, aborted, API, and unknown errors to the established V2 string conventions,
preserve the message, and discard V1-only retryability and raw provider details.
Ignore V1 `retry` parts. Do not populate the V2 assistant `retry` field during migration; historical retry state is not
useful enough to preserve. The original retry rows remain in the V1 `part` table.
Do not emit V2 assistant content for V1 `step-start` and `step-finish` parts. Use the first available
`step-start.snapshot` as `assistant.snapshot.start` and the last available `step-finish.snapshot` as
`assistant.snapshot.end`. Continue to source finish, cost, and tokens from the assistant message itself. Ignore step
markers without snapshots.
Do not emit assistant content for standalone V1 `snapshot` or `patch` parts. If no start snapshot came from `step-start`,
use the first standalone snapshot value, then the first patch hash as a final fallback. Only `step-finish.snapshot` may
populate the end snapshot. Merge patch file lists into `assistant.snapshot.files` in first-seen order with duplicates
removed.
V2 follow-up: replace the open `SessionError.Error` string shape with a properly typed persisted error union. This is not
a blocker for the V1 migration, which should target the current storage contract.
V1 synthetic content is represented by user text parts with `synthetic: true`, not by a separate message role. A V1 user
message whose visible text parts are all synthetic should become a V2 `synthetic` message. If a V1 user message mixes
ordinary and synthetic content, preserve the ordinary content in the V2 `user` row and emit the synthetic content as an
adjacent V2 `synthetic` row. Ignore text parts marked `ignored`, matching V1 model-history behavior.
For an ordinary V2 user message, take visible V1 text parts that are neither ignored nor synthetic, preserve part order,
and join their text with `"\n\n"`. Use an empty string when the message contains attachments but no ordinary text.
Ignore the optional V1 user-message `system` override. Do not create a V2 system message or preserve the override in
metadata. The original value remains in the V1 `message` row.
Ignore the optional V1 user-message `tools` map. It represented request-time tool enablement for a historical step and
must not affect future V2 execution. The original value remains in the V1 `message` row.
Ignore the optional V1 user-message `format` field and its schema. It controlled structured-output behavior for a
historical request and must not affect future V2 runs. Preserve visible assistant text normally; retain the original
format only in the V1 `message` row.
Ignore V1 user-message `summary` metadata, including title, body, and diffs. V2 user messages have no equivalent field,
and session-level summary data is already persisted separately. Retain the original summary only in the V1 `message`
row.
Map V1 `agent` parts into the V2 user message's `agents` array in part order. Preserve `name`. When the V1 part has
`source`, map its `value`, `start`, and `end` into the V2 attachment's `mention.text`, `mention.start`, and `mention.end`.
Omit `agents` when there are no agent parts.
Do not read the filesystem or network while migrating V1 file attachments. Attachment migration must be deterministic
from database contents alone. Convert persisted `data:` URLs; represent non-embedded `file:`, HTTP, and other external
URLs with deterministic text rather than fetching them. Keep the original V1 `part` rows unchanged.
For a V1 file backed by a `data:` URL, decode the URL and normalize its payload to base64 for the V2 attachment's `data`.
Preserve `mime` and optional `filename` as `name`. Use a V2 `uri` source with the original URI for a V1 resource source;
otherwise use an `inline` source. When V1 source text metadata exists, map its `value`, `start`, and `end` into the V2
attachment mention. Leave `description` unset and preserve file-part order in the V2 `files` array.
For a non-embedded V1 file, do not create a V2 file attachment. Append
`[Attachment unavailable after migration: <name-or-url> (<mime>)]` to the V2 user text in original part order, separated
by blank lines. Prefer the V1 filename, then resource URI, then part URL for the label. The original URL remains only in
the preserved V1 `part` row.
For a synthetic row split from a mixed user message, derive a generated-looking ID from the source message ID. Preserve
the source ID's 12-character timestamp component and replace its 14-character random component with a deterministic
base-62 encoding of a hash of `v1-synthetic:` plus the source message ID. If that candidate collides with an existing or
derived message ID, deterministically retry with an incrementing salt. Place the synthetic row immediately after its
source user row. Entirely synthetic messages continue to reuse their original message ID.
Use the V1 compaction user message ID as the ID of the collapsed V2 compaction message. This matches V2's use of the
admitted compaction input ID and preserves references to the initiating message.
For a completed compaction, create one V2 `compaction` row with `status: "completed"`. Set `reason` from the V1
compaction part's `auto` flag, join the paired summary assistant's nonempty text parts with blank lines for `summary`, and
serialize the retained V1 tail beginning at `tail_start_id` for `recent`. Use an empty `recent` value when no tail was
retained, and use the compaction user message creation time. Do not emit the paired summary assistant as a separate V2
assistant row.
Do not project incomplete or failed V1 compactions into `session_message`. Omit both the internal compaction user marker
and its paired summary assistant when no successful summary was completed. Assign final sequence numbers after filtering
so omitted compactions leave no gaps. Their source rows remain preserved in the V1 `message` and `part` tables.
After rebuilding a session's `session_message`, replace its `event_sequence` watermark with that session's maximum
backfilled `session_message.seq`. This prevents new V2 events from reusing sequence numbers or sorting before migrated
history. The migrated session's prior `event` rows are removed in the same transaction.
## Drop
Drop these pre-launch V2 tables without preserving or transforming their rows:
- `session_input`
- `session_context_epoch`
- `data_migration`
Do not transfer `session_input` rows into `session_pending`.
## Create Empty
Let the generated migration create these tables empty:
- `instruction_blob`
- `instruction_entry`
- `instruction_state`
- `session_pending`
- `kv`
V1 has no canonical data to backfill into these tables. V2 initializes their state as it runs.
## Fork Storage
V1 has no fork-boundary state to backfill. New V2 forks use a required message boundary and persist it in
`session.fork_boundary`. The durable fork event contains no parent sequence. Its resolved boundary is one of:
- `before`: copy messages before the identified message.
- `through`: copy messages through the identified message.
Forking an empty session is not supported. `session.fork_seq` and `session.fork_message_id` are not part of the final V2
schema.
New nullable session columns, including `fork_session_id`, `fork_boundary`, and `time_suspended`, require no explicit
backfill. Existing rows naturally receive `NULL` when the generated migration adds the columns.
## Execution
Before transforming V1 rows, look for `opencode-next.db` in the data directory. This file was used by pre-launch V2
builds. Open it read-only with Bun SQLite and copy its `project`, `session`, and `session_message` rows directly into the
current `project`, `session_v2`, and `session_message` tables. Existing current projects and Sessions win ID collisions.
Do not copy its durable events or runtime caches; initialize each imported Session's `event_sequence` watermark from its
maximum message sequence. Commit each imported Session independently and leave the source database untouched.
The previous V2 import is part of this migration and uses the same completion marker. It needs no source-specific cursor:
the destination Session row is the per-Session idempotency boundary, so a retry skips transactions that already committed.
Store V1 backfill state in `kv`; do not retain a dedicated `data_migration` table. Store the last successfully migrated
session ID under `migration.v1-v2.session.cursor` and write `migration.v1-v2.completed` with value `true` after every
session finishes. Delete the cursor key on completion and return immediately on later calls when the completion key
exists.
Absence of the completion key means migration is required, including on a fresh database. Running the endpoint against a
database with no sessions completes immediately and writes the completion key; fresh database initialization does not
seed migration state specially.
Process sessions in stable ID order. Rebuild one session in one transaction, including its `session_message` rows,
session-level backfills, `event_sequence` watermark, and cursor update. If interrupted during a session, that transaction
rolls back and the next endpoint call retries the same session. If it committed, the next call continues after the stored
cursor. Mark the migration complete after the final session and return immediately on later calls.
Ensure the global project exists using the current platform's filesystem root as its worktree. Process every `session`
row, including archived, root, child, and empty sessions, as well as sessions whose messages are all skipped or internal.
Reassign beta and V1 Sessions whose referenced project row is missing to the global project and log a warning. Each
successfully committed session advances the cursor.
## Testing
Detailed migration test design is deferred until after the canonical migration is implemented.
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1790510107,
"narHash": "sha256-EVMNYv7hYDDD9TGVT/hIyTYgpiXA8y3m5xIEIxuGNU0=",
"lastModified": 1776683584,
"narHash": "sha256-NuTLMrr10Tng72hurYG8jYQ4XKK8wnpJmOGcPiis96g=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3181085bfd08663b6b9e60bc7a8395c2aaa741bd",
"rev": "9dd5558b06dbdacbf635a3dd36dce1b1a7ee3a89",
"type": "github"
},
"original": {
Loaded 100 of 6435 files, more files were not shown because too many files have changed in this diff. Show more