Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a74b79834 |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Preserve prompt cache prefixes when sessions move between locations with unchanged instructions.
|
||||
@@ -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
|
||||
@@ -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') }}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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:]
|
||||
---
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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")"
|
||||
|
||||
@@ -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.`;
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -32,7 +32,6 @@ target
|
||||
# Local dev files
|
||||
opencode-dev
|
||||
UPCOMING_CHANGELOG.md
|
||||
RELEASE_REVIEW.md
|
||||
logs/
|
||||
*.bun-build
|
||||
tsconfig.tsbuildinfo
|
||||
@@ -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
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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,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,5 +1,5 @@
|
||||
/// <reference path="../env.d.ts" />
|
||||
import { tool } from "@opencode/plugin"
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
|
||||
const TEAM = {
|
||||
tui: ["kommander", "simonklee"],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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. |
|
||||
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 40 KiB |
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 },
|
||||
]
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 é
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
@@ -1,2 +0,0 @@
|
||||
First line
|
||||
Last line without trailing newline
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 222 KiB |
|
Before Width: | Height: | Size: 309 KiB |
|
Before Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 322 KiB |
|
Before Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 299 KiB |
|
Before Width: | Height: | Size: 252 KiB |
|
Before Width: | Height: | Size: 249 KiB |
|
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;
|
||||
|
Before Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 222 KiB |
|
Before Width: | Height: | Size: 309 KiB |
|
Before Width: | Height: | Size: 293 KiB |
|
Before Width: | Height: | Size: 323 KiB |
|
Before Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 298 KiB |
|
Before Width: | Height: | Size: 252 KiB |
|
Before Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 290 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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": {
|
||||
|
||||