Compare commits

..

1 Commits

Author SHA1 Message Date
opencode
2ec4749bf7 release: v1.14.32 2026-05-02 15:34:01 +00:00
388 changed files with 21681 additions and 39462 deletions

View File

@@ -1,5 +1,6 @@
name: Bug report
description: Report an issue that should be fixed
labels: ["bug"]
body:
- type: textarea
id: description

View File

@@ -1,5 +1,6 @@
name: 🚀 Feature Request
description: Suggest an idea, feature, or enhancement
labels: [discussion]
title: "[FEATURE]:"
body:

View File

@@ -1,5 +1,6 @@
name: Question
description: Ask a question
labels: ["question"]
body:
- type: textarea
id: question

View File

@@ -11,5 +11,6 @@ MrMushrooooom
nexxeln
R44VC0RP
rekram1-node
RhysSullivan
thdxr
simonklee

41
.github/VOUCHED.td vendored Normal file
View File

@@ -0,0 +1,41 @@
# Vouched contributors for this project.
#
# See https://github.com/mitchellh/vouch for details.
#
# Syntax:
# - One handle per line (without @), sorted alphabetically.
# - Optional platform prefix: platform:username (e.g., github:user).
# - Denounce with minus prefix: -username or -platform:username.
# - Optional details after a space following the handle.
adamdotdevin
-agusbasari29 AI PR slop
ariane-emory
-atharvau AI review spamming literally every PR
-borealbytes
-carycooper777
-danieljoshuanazareth
-danieljoshuanazareth
-davidbernat looks to be a clawdbot that spams team and sends super weird emails, doesnt appear to be a real person
dmtrkovalenko
edemaine
fahreddinozcan
-florianleibert
fwang
iamdavidhill
jayair
kitlangton
kommander
-opencode2026
-opencodeengineer bot that spams issues
r44vc0rp
rekram1-node
-ricardo-m-l
-robinmordasiewicz
rubdos
-saisharan0103 spamming ai prs
shantur
simonklee
-spider-yamet clawdbot/llm psychosis, spam pinging the team
-terisuke
thdxr
-toastythebot

170
.github/workflows/daily-issues-recap.yml vendored Normal file
View File

@@ -0,0 +1,170 @@
name: daily-issues-recap
on:
schedule:
# Run at 6 PM EST (23:00 UTC, or 22:00 UTC during daylight saving)
- cron: "0 23 * * *"
workflow_dispatch: # Allow manual trigger for testing
jobs:
daily-recap:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
issues: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- uses: ./.github/actions/setup-bun
- name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash
- name: Generate daily issues recap
id: recap
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENCODE_PERMISSION: |
{
"bash": {
"*": "deny",
"gh issue*": "allow",
"gh search*": "allow"
},
"webfetch": "deny",
"edit": "deny",
"write": "deny"
}
run: |
# Get today's date range
TODAY=$(date -u +%Y-%m-%d)
opencode run -m opencode/claude-sonnet-4-5 "Generate a daily issues recap for the OpenCode repository.
TODAY'S DATE: ${TODAY}
STEP 1: Gather today's issues
Search for all OPEN issues created today (${TODAY}) using:
gh issue list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,body,labels,state,comments,createdAt,author --limit 500
IMPORTANT: EXCLUDE all issues authored by Anomaly team members. Filter out issues where the author login matches ANY of these:
adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr
This recap is specifically for COMMUNITY (external) issues only.
STEP 2: Analyze and categorize
For each issue created today, categorize it:
**Severity Assessment:**
- CRITICAL: Crashes, data loss, security issues, blocks major functionality
- HIGH: Significant bugs affecting many users, important features broken
- MEDIUM: Bugs with workarounds, minor features broken
- LOW: Minor issues, cosmetic, nice-to-haves
**Activity Assessment:**
- Note issues with high comment counts or engagement
- Note issues from repeat reporters (check if author has filed before)
STEP 3: Cross-reference with existing issues
For issues that seem like feature requests or recurring bugs:
- Search for similar older issues to identify patterns
- Note if this is a frequently requested feature
- Identify any issues that are duplicates of long-standing requests
STEP 4: Generate the recap
Create a structured recap with these sections:
===DISCORD_START===
**Daily Issues Recap - ${TODAY}**
**Summary Stats**
- Total issues opened today: [count]
- By category: [bugs/features/questions]
**Critical/High Priority Issues**
[List any CRITICAL or HIGH severity issues with brief descriptions and issue numbers]
**Most Active/Discussed**
[Issues with significant engagement or from active community members]
**Trending Topics**
[Patterns noticed - e.g., 'Multiple reports about X', 'Continued interest in Y feature']
**Duplicates & Related**
[Issues that relate to existing open issues]
===DISCORD_END===
STEP 5: Format for Discord
Format the recap as a Discord-compatible message:
- Use Discord markdown (**, __, etc.)
- BE EXTREMELY CONCISE - this is an EOD summary, not a detailed report
- Use hyperlinked issue numbers with suppressed embeds: [#1234](<https://github.com/${{ github.repository }}/issues/1234>)
- Group related issues on single lines where possible
- Add emoji sparingly for critical items only
- HARD LIMIT: Keep under 1800 characters total
- Skip sections that have nothing notable (e.g., if no critical issues, omit that section)
- Prioritize signal over completeness - only surface what matters
OUTPUT: Output ONLY the content between ===DISCORD_START=== and ===DISCORD_END=== markers. Include the markers so I can extract it." > /tmp/recap_raw.txt
# Extract only the Discord message between markers
sed -n '/===DISCORD_START===/,/===DISCORD_END===/p' /tmp/recap_raw.txt | grep -v '===DISCORD' > /tmp/recap.txt
echo "recap_file=/tmp/recap.txt" >> $GITHUB_OUTPUT
- name: Post to Discord
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }}
run: |
if [ -z "$DISCORD_WEBHOOK_URL" ]; then
echo "Warning: DISCORD_ISSUES_WEBHOOK_URL secret not set, skipping Discord post"
cat /tmp/recap.txt
exit 0
fi
# Read the recap
RECAP_RAW=$(cat /tmp/recap.txt)
RECAP_LENGTH=${#RECAP_RAW}
echo "Recap length: ${RECAP_LENGTH} chars"
# Function to post a message to Discord
post_to_discord() {
local msg="$1"
local content=$(echo "$msg" | jq -Rs '.')
curl -s -H "Content-Type: application/json" \
-X POST \
-d "{\"content\": ${content}}" \
"$DISCORD_WEBHOOK_URL"
sleep 1
}
# If under limit, send as single message
if [ "$RECAP_LENGTH" -le 1950 ]; then
post_to_discord "$RECAP_RAW"
else
echo "Splitting into multiple messages..."
remaining="$RECAP_RAW"
while [ ${#remaining} -gt 0 ]; do
if [ ${#remaining} -le 1950 ]; then
post_to_discord "$remaining"
break
else
chunk="${remaining:0:1900}"
last_newline=$(echo "$chunk" | grep -bo $'\n' | tail -1 | cut -d: -f1)
if [ -n "$last_newline" ] && [ "$last_newline" -gt 500 ]; then
chunk="${remaining:0:$last_newline}"
remaining="${remaining:$((last_newline+1))}"
else
chunk="${remaining:0:1900}"
remaining="${remaining:1900}"
fi
post_to_discord "$chunk"
fi
done
fi
echo "Posted daily recap to Discord"

173
.github/workflows/daily-pr-recap.yml vendored Normal file
View File

@@ -0,0 +1,173 @@
name: daily-pr-recap
on:
schedule:
# Run at 5pm EST (22:00 UTC, or 21:00 UTC during daylight saving)
- cron: "0 22 * * *"
workflow_dispatch: # Allow manual trigger for testing
jobs:
pr-recap:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
pull-requests: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- uses: ./.github/actions/setup-bun
- name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash
- name: Generate daily PR recap
id: recap
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENCODE_PERMISSION: |
{
"bash": {
"*": "deny",
"gh pr*": "allow",
"gh search*": "allow"
},
"webfetch": "deny",
"edit": "deny",
"write": "deny"
}
run: |
TODAY=$(date -u +%Y-%m-%d)
opencode run -m opencode/claude-sonnet-4-5 "Generate a daily PR activity recap for the OpenCode repository.
TODAY'S DATE: ${TODAY}
STEP 1: Gather PR data
Run these commands to gather PR information. ONLY include OPEN PRs created or updated TODAY (${TODAY}):
# Open PRs created today
gh pr list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100
# Open PRs with activity today (updated today)
gh pr list --repo ${{ github.repository }} --state open --search \"updated:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100
IMPORTANT: EXCLUDE all PRs authored by Anomaly team members. Filter out PRs where the author login matches ANY of these:
adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr
This recap is specifically for COMMUNITY (external) contributions only.
STEP 2: For high-activity PRs, check comment counts
For promising PRs, run:
gh pr view [NUMBER] --repo ${{ github.repository }} --json comments --jq '[.comments[] | select(.author.login != \"copilot-pull-request-reviewer\" and .author.login != \"github-actions\")] | length'
IMPORTANT: When counting comments/activity, EXCLUDE these bot accounts:
- copilot-pull-request-reviewer
- github-actions
STEP 3: Identify what matters (ONLY from today's PRs)
**Bug Fixes From Today:**
- PRs with 'fix' or 'bug' in title created/updated today
- Small bug fixes (< 100 lines changed) that are easy to review
- Bug fixes from community contributors
**High Activity Today:**
- PRs with significant human comments today (excluding bots listed above)
- PRs with back-and-forth discussion today
**Quick Wins:**
- Small PRs (< 50 lines) that are approved or nearly approved
- PRs that just need a final review
STEP 4: Generate the recap
Create a structured recap:
===DISCORD_START===
**Daily PR Recap - ${TODAY}**
**New PRs Today**
[PRs opened today - group by type: bug fixes, features, etc.]
**Active PRs Today**
[PRs with activity/updates today - significant discussion]
**Quick Wins**
[Small PRs ready to merge]
===DISCORD_END===
STEP 5: Format for Discord
- Use Discord markdown (**, __, etc.)
- BE EXTREMELY CONCISE - surface what we might miss
- Use hyperlinked PR numbers with suppressed embeds: [#1234](<https://github.com/${{ github.repository }}/pull/1234>)
- Include PR author: [#1234](<url>) (@author)
- For bug fixes, add brief description of what it fixes
- Show line count for quick wins: \"(+15/-3 lines)\"
- HARD LIMIT: Keep under 1800 characters total
- Skip empty sections
- Focus on PRs that need human eyes
OUTPUT: Output ONLY the content between ===DISCORD_START=== and ===DISCORD_END=== markers. Include the markers so I can extract it." > /tmp/pr_recap_raw.txt
# Extract only the Discord message between markers
sed -n '/===DISCORD_START===/,/===DISCORD_END===/p' /tmp/pr_recap_raw.txt | grep -v '===DISCORD' > /tmp/pr_recap.txt
echo "recap_file=/tmp/pr_recap.txt" >> $GITHUB_OUTPUT
- name: Post to Discord
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }}
run: |
if [ -z "$DISCORD_WEBHOOK_URL" ]; then
echo "Warning: DISCORD_ISSUES_WEBHOOK_URL secret not set, skipping Discord post"
cat /tmp/pr_recap.txt
exit 0
fi
# Read the recap
RECAP_RAW=$(cat /tmp/pr_recap.txt)
RECAP_LENGTH=${#RECAP_RAW}
echo "Recap length: ${RECAP_LENGTH} chars"
# Function to post a message to Discord
post_to_discord() {
local msg="$1"
local content=$(echo "$msg" | jq -Rs '.')
curl -s -H "Content-Type: application/json" \
-X POST \
-d "{\"content\": ${content}}" \
"$DISCORD_WEBHOOK_URL"
sleep 1
}
# If under limit, send as single message
if [ "$RECAP_LENGTH" -le 1950 ]; then
post_to_discord "$RECAP_RAW"
else
echo "Splitting into multiple messages..."
remaining="$RECAP_RAW"
while [ ${#remaining} -gt 0 ]; do
if [ ${#remaining} -le 1950 ]; then
post_to_discord "$remaining"
break
else
chunk="${remaining:0:1900}"
last_newline=$(echo "$chunk" | grep -bo $'\n' | tail -1 | cut -d: -f1)
if [ -n "$last_newline" ] && [ "$last_newline" -gt 500 ]; then
chunk="${remaining:0:$last_newline}"
remaining="${remaining:$((last_newline+1))}"
else
chunk="${remaining:0:1900}"
remaining="${remaining:1900}"
fi
post_to_discord "$chunk"
fi
done
fi
echo "Posted daily PR recap to Discord"

View File

@@ -209,6 +209,182 @@ jobs:
packages/opencode/dist/opencode-windows-x64
packages/opencode/dist/opencode-windows-x64-baseline
build-tauri:
needs:
- build-cli
- version
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
strategy:
fail-fast: false
matrix:
settings:
- host: macos-latest
target: x86_64-apple-darwin
- host: macos-latest
target: aarch64-apple-darwin
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
- host: windows-2025
target: aarch64-pc-windows-msvc
- host: blacksmith-4vcpu-windows-2025
target: x86_64-pc-windows-msvc
- host: blacksmith-4vcpu-ubuntu-2404
target: x86_64-unknown-linux-gnu
- host: blacksmith-8vcpu-ubuntu-2404-arm
target: aarch64-unknown-linux-gnu
runs-on: ${{ matrix.settings.host }}
steps:
- uses: actions/checkout@v3
with:
fetch-tags: true
- uses: apple-actions/import-codesign-certs@v2
if: ${{ runner.os == 'macOS' }}
with:
keychain: build
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Verify Certificate
if: ${{ runner.os == 'macOS' }}
run: |
CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application")
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV
echo "Certificate imported."
- name: Setup Apple API Key
if: ${{ runner.os == 'macOS' }}
run: |
echo "${{ secrets.APPLE_API_KEY_PATH }}" > $RUNNER_TEMP/apple-api-key.p8
- uses: ./.github/actions/setup-bun
- name: Azure login
if: runner.os == 'Windows'
uses: azure/login@v2
with:
client-id: ${{ env.AZURE_CLIENT_ID }}
tenant-id: ${{ env.AZURE_TENANT_ID }}
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Cache apt packages
if: contains(matrix.settings.host, 'ubuntu')
uses: actions/cache@v4
with:
path: ~/apt-cache
key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-${{ hashFiles('.github/workflows/publish.yml') }}
restore-keys: |
${{ runner.os }}-${{ matrix.settings.target }}-apt-
- name: install dependencies (ubuntu only)
if: contains(matrix.settings.host, 'ubuntu')
run: |
mkdir -p ~/apt-cache && chmod -R a+rw ~/apt-cache
sudo apt-get update
sudo apt-get install -y --no-install-recommends -o dir::cache::archives="$HOME/apt-cache" libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
sudo chmod -R a+rw ~/apt-cache
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.settings.target }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: packages/desktop/src-tauri
shared-key: ${{ matrix.settings.target }}
- name: Prepare
run: |
cd packages/desktop
bun ./scripts/prepare.ts
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
- name: Resolve tauri portable SHA
if: contains(matrix.settings.host, 'ubuntu')
run: echo "TAURI_PORTABLE_SHA=$(git ls-remote https://github.com/tauri-apps/tauri.git refs/heads/feat/truly-portable-appimage | cut -f1)" >> "$GITHUB_ENV"
# Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released
- name: Install tauri-cli from portable appimage branch
uses: taiki-e/cache-cargo-install-action@v3
if: contains(matrix.settings.host, 'ubuntu')
with:
tool: tauri-cli
git: https://github.com/tauri-apps/tauri
# branch: feat/truly-portable-appimage
rev: ${{ env.TAURI_PORTABLE_SHA }}
- name: Show tauri-cli version
if: contains(matrix.settings.host, 'ubuntu')
run: cargo tauri --version
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Build and upload artifacts
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
timeout-minutes: 60
with:
projectPath: packages/desktop
uploadWorkflowArtifacts: true
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
args: --target ${{ matrix.settings.target }} --config ${{ (github.ref_name == 'beta' && './src-tauri/tauri.beta.conf.json') || './src-tauri/tauri.prod.conf.json' }} --verbose
updaterJsonPreferNsis: true
releaseId: ${{ needs.version.outputs.release }}
tagName: ${{ needs.version.outputs.tag }}
releaseDraft: true
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
repo: ${{ (github.ref_name == 'beta' && 'opencode-beta') || '' }}
releaseCommitish: ${{ github.sha }}
env:
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
- name: Verify signed Windows desktop artifacts
if: runner.os == 'Windows'
shell: pwsh
run: |
$files = @(
"${{ github.workspace }}\packages\desktop\src-tauri\sidecars\opencode-cli-${{ matrix.settings.target }}.exe"
)
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop\src-tauri\target\${{ matrix.settings.target }}\release\bundle\nsis\*.exe" | Select-Object -ExpandProperty FullName
foreach ($file in $files) {
$sig = Get-AuthenticodeSignature $file
if ($sig.Status -ne "Valid") {
throw "Invalid signature for ${file}: $($sig.Status)"
}
}
build-electron:
needs:
- build-cli
@@ -348,30 +524,6 @@ jobs:
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
- name: Create and upload macOS .app.tar.gz
if: runner.os == 'macOS' && needs.version.outputs.release
working-directory: packages/desktop-electron/dist
env:
GH_TOKEN: ${{ steps.committer.outputs.token }}
run: |
if [[ "${{ matrix.settings.target }}" == "x86_64-apple-darwin" ]]; then
APP_DIR="mac"
OUT_NAME="opencode-desktop-mac-x64.app.tar.gz"
elif [[ "${{ matrix.settings.target }}" == "aarch64-apple-darwin" ]]; then
APP_DIR="mac-arm64"
OUT_NAME="opencode-desktop-mac-arm64.app.tar.gz"
else
echo "Unknown macOS target: ${{ matrix.settings.target }}"
exit 1
fi
APP_PATH=$(find "$APP_DIR" -maxdepth 1 -name "*.app" -type d | head -1)
if [ -z "$APP_PATH" ]; then
echo "No .app bundle found in $APP_DIR"
exit 1
fi
tar -czf "$OUT_NAME" -C "$(dirname "$APP_PATH")" "$(basename "$APP_PATH")"
gh release upload "v${{ needs.version.outputs.version }}" "$OUT_NAME" --clobber --repo "${{ needs.version.outputs.repo }}"
- name: Verify signed Windows Electron artifacts
if: runner.os == 'Windows'
shell: pwsh
@@ -390,7 +542,7 @@ jobs:
- uses: actions/upload-artifact@v4
with:
name: opencode-desktop-${{ matrix.settings.target }}
name: opencode-electron-${{ matrix.settings.target }}
path: packages/desktop-electron/dist/*
- uses: actions/upload-artifact@v4
@@ -404,6 +556,7 @@ jobs:
- version
- build-cli
- sign-cli-windows
- build-tauri
- build-electron
if: always() && !failure() && !cancelled()
runs-on: blacksmith-4vcpu-ubuntu-2404
@@ -430,6 +583,13 @@ jobs:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- uses: actions/download-artifact@v4
with:
name: opencode-cli
@@ -451,13 +611,6 @@ jobs:
pattern: latest-yml-*
path: /tmp/latest-yml
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Cache apt packages (AUR)
uses: actions/cache@v4
with:
@@ -486,5 +639,3 @@ jobs:
GH_REPO: ${{ needs.version.outputs.repo }}
NPM_CONFIG_PROVENANCE: false
LATEST_YML_DIR: /tmp/latest-yml
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}

116
.github/workflows/vouch-check-issue.yml vendored Normal file
View File

@@ -0,0 +1,116 @@
name: vouch-check-issue
on:
issues:
types: [opened]
permissions:
contents: read
issues: write
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Check if issue author is denounced
uses: actions/github-script@v7
with:
script: |
const author = context.payload.issue.user.login;
const issueNumber = context.payload.issue.number;
// Skip bots
if (author.endsWith('[bot]')) {
core.info(`Skipping bot: ${author}`);
return;
}
// Read the VOUCHED.td file via API (no checkout needed)
let content;
try {
const response = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/VOUCHED.td',
});
content = Buffer.from(response.data.content, 'base64').toString('utf-8');
} catch (error) {
if (error.status === 404) {
core.info('No .github/VOUCHED.td file found, skipping check.');
return;
}
throw error;
}
// Parse the .td file for vouched and denounced users
const vouched = new Set();
const denounced = new Map();
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const isDenounced = trimmed.startsWith('-');
const rest = isDenounced ? trimmed.slice(1).trim() : trimmed;
if (!rest) continue;
const spaceIdx = rest.indexOf(' ');
const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim();
// Handle platform:username or bare username
// Only match bare usernames or github: prefix (skip other platforms)
const colonIdx = handle.indexOf(':');
if (colonIdx !== -1) {
const platform = handle.slice(0, colonIdx).toLowerCase();
if (platform !== 'github') continue;
}
const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1);
if (!username) continue;
if (isDenounced) {
denounced.set(username.toLowerCase(), reason);
continue;
}
vouched.add(username.toLowerCase());
}
// Check if the author is denounced
const reason = denounced.get(author.toLowerCase());
if (reason !== undefined) {
// Author is denounced — close the issue
const body = 'This issue has been automatically closed.';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body,
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
state: 'closed',
state_reason: 'not_planned',
});
core.info(`Closed issue #${issueNumber} from denounced user ${author}`);
return;
}
// Author is positively vouched — add label
if (!vouched.has(author.toLowerCase())) {
core.info(`User ${author} is not denounced or vouched. Allowing issue.`);
return;
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels: ['Vouched'],
});
core.info(`Added vouched label to issue #${issueNumber} from ${author}`);

114
.github/workflows/vouch-check-pr.yml vendored Normal file
View File

@@ -0,0 +1,114 @@
name: vouch-check-pr
on:
pull_request_target:
types: [opened]
permissions:
contents: read
issues: write
pull-requests: write
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Check if PR author is denounced
uses: actions/github-script@v7
with:
script: |
const author = context.payload.pull_request.user.login;
const prNumber = context.payload.pull_request.number;
// Skip bots
if (author.endsWith('[bot]')) {
core.info(`Skipping bot: ${author}`);
return;
}
// Read the VOUCHED.td file via API (no checkout needed)
let content;
try {
const response = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/VOUCHED.td',
});
content = Buffer.from(response.data.content, 'base64').toString('utf-8');
} catch (error) {
if (error.status === 404) {
core.info('No .github/VOUCHED.td file found, skipping check.');
return;
}
throw error;
}
// Parse the .td file for vouched and denounced users
const vouched = new Set();
const denounced = new Map();
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const isDenounced = trimmed.startsWith('-');
const rest = isDenounced ? trimmed.slice(1).trim() : trimmed;
if (!rest) continue;
const spaceIdx = rest.indexOf(' ');
const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim();
// Handle platform:username or bare username
// Only match bare usernames or github: prefix (skip other platforms)
const colonIdx = handle.indexOf(':');
if (colonIdx !== -1) {
const platform = handle.slice(0, colonIdx).toLowerCase();
if (platform !== 'github') continue;
}
const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1);
if (!username) continue;
if (isDenounced) {
denounced.set(username.toLowerCase(), reason);
continue;
}
vouched.add(username.toLowerCase());
}
// Check if the author is denounced
const reason = denounced.get(author.toLowerCase());
if (reason !== undefined) {
// Author is denounced — close the PR
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: 'This pull request has been automatically closed.',
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
state: 'closed',
});
core.info(`Closed PR #${prNumber} from denounced user ${author}`);
return;
}
// Author is positively vouched — add label
if (!vouched.has(author.toLowerCase())) {
core.info(`User ${author} is not denounced or vouched. Allowing PR.`);
return;
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: ['Vouched'],
});
core.info(`Added vouched label to PR #${prNumber} from ${author}`);

View File

@@ -0,0 +1,38 @@
name: vouch-manage-by-issue
on:
issue_comment:
types: [created]
concurrency:
group: vouch-manage
cancel-in-progress: false
permissions:
contents: write
issues: write
pull-requests: read
jobs:
manage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- uses: mitchellh/vouch/action/manage-by-issue@main
with:
issue-id: ${{ github.event.issue.number }}
comment-id: ${{ github.event.comment.id }}
roles: admin,maintain,write
env:
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}

View File

@@ -1,7 +1,7 @@
---
mode: primary
hidden: true
model: opencode/gpt-5.4-nano
model: opencode/minimax-m2.5
color: "#44BA81"
tools:
"*": false
@@ -14,30 +14,127 @@ Use your github-triage tool to triage issues.
This file is the source of truth for ownership/routing rules.
Assign issues by choosing the team with the strongest overlap. The github-triage tool will assign a random member from that team.
## Labels
Do not add labels to issues. Only assign an owner.
### windows
When calling github-triage, pass one of these team values: tui, desktop_web, core, inference, windows.
Use for any issue that mentions Windows (the OS). Be sure they are saying that they are on Windows.
## Teams
- Use if they mention WSL too
### TUI
#### perf
Terminal UI issues, including rendering, keybindings, scrolling, terminal compatibility, SSH behavior, crashes in the TUI, and low-level TUI performance.
Performance-related issues:
### Desktop / Web
- Slow performance
- High RAM usage
- High CPU usage
Desktop application and browser-based app issues, including `opencode web`, desktop-specific UI behavior, packaging, and web view problems.
**Only** add if it's likely a RAM or CPU issue. **Do not** add for LLM slowness.
### Core
#### desktop
Core opencode server and harness issues, including sqlite, snapshots, memory, API behavior, agent context construction, tool execution, provider integrations, model behavior, documentation, and larger architectural features.
Desktop app issues:
### Inference
- `opencode web` command
- The desktop app itself
OpenCode Zen, OpenCode Go, and billing issues.
**Only** add if it's specifically about the Desktop application or `opencode web` view. **Do not** add for terminal, TUI, or general opencode issues.
### Windows
#### nix
Windows-specific issues, including native Windows behavior, WSL interactions, path handling, shell compatibility, and installation or runtime problems that only happen on Windows.
**Only** add if the issue explicitly mentions nix.
If the issue does not mention nix, do not add nix.
If the issue mentions nix, assign to `rekram1-node`.
#### zen
**Only** add if the issue mentions "zen" or "opencode zen" or "opencode black".
If the issue doesn't have "zen" or "opencode black" in it then don't add zen label
#### core
Use for core server issues in `packages/opencode/`, excluding `packages/opencode/src/cli/cmd/tui/`.
Examples:
- LSP server behavior
- Harness behavior (agent + tools)
- Feature requests for server behavior
- Agent context construction
- API endpoints
- Provider integration issues
- New, broken, or poor-quality models
#### acp
If the issue mentions acp support, assign acp label.
#### docs
Add if the issue requests better documentation or docs updates.
#### opentui
TUI issues potentially caused by our underlying TUI library:
- Keybindings not working
- Scroll speed issues (too fast/slow/laggy)
- Screen flickering
- Crashes with opentui in the log
**Do not** add for general TUI bugs.
When assigning to people here are the following rules:
Desktop / Web:
Use for desktop-labeled issues only.
- adamdotdevin
- iamdavidhill
- Brendonovich
- nexxeln
Zen:
ONLY assign if the issue will have the "zen" label.
- fwang
- MrMushrooooom
TUI (`packages/opencode/src/cli/cmd/tui/...`):
- thdxr for TUI UX/UI product decisions and interaction flow
- kommander for OpenTUI engine issues: rendering artifacts, keybind handling, terminal compatibility, SSH behavior, and low-level perf bottlenecks
- rekram1-node for TUI bugs that are not clearly OpenTUI engine issues
Core (`packages/opencode/...`, excluding TUI subtree):
- thdxr for sqlite/snapshot/memory bugs and larger architectural core features
- jlongster for opencode server + API feature work (tool currently remaps jlongster -> thdxr until assignable)
- rekram1-node for harness issues, provider issues, and other bug-squashing
For core bugs that do not clearly map, either thdxr or rekram1-node is acceptable.
Docs:
- R44VC0RP
Windows:
- Hona (assign any issue that mentions Windows or is likely Windows-specific)
Determinism rules:
- If title + body does not contain "zen", do not add the "zen" label
- If "nix" label is added but title + body does not mention nix/nixos, the tool will drop "nix"
- If title + body mentions nix/nixos, assign to `rekram1-node`
- If "desktop" label is added, the tool will override assignee and randomly pick one Desktop / Web owner
In all other cases, choose the team/section with the most overlap with the issue and assign a member from that team at random.
ACP:
- rekram1-node (assign any acp issues to rekram1-node)

View File

@@ -18,12 +18,9 @@ Do not use `git log` or author metadata when deciding attribution.
Rules:
- Write the final file with release sections in this order:
- Write the final file with sections in this order:
`## Core`, `## TUI`, `## Desktop`, `## SDK`, `## Extensions`
- Only include sections that have at least one notable entry
- Within each release section, keep bug fixes grouped under `### Bugfixes`
- Keep other notable entries under `### Improvements` when a section has bug fixes too
- Omit empty subsections
- Keep one bullet per commit you keep
- Skip commits that are entirely internal, CI, tests, refactors, or otherwise not user-facing
- Start each bullet with a capital letter

View File

@@ -1,14 +1,16 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
const TEAM = {
tui: ["kommander", "simonklee"],
desktop_web: ["Hona", "Brendonovich"],
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
inference: ["fwang", "MrMushrooooom"],
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
zen: ["fwang", "MrMushrooooom"],
tui: ["kommander", "rekram1-node", "simonklee"],
core: ["kitlangton", "rekram1-node", "jlongster"],
docs: ["R44VC0RP"],
windows: ["Hona"],
} as const
const ASSIGNEES = [...new Set(Object.values(TEAM).flat())]
function pick<T>(items: readonly T[]) {
return items[Math.floor(Math.random() * items.length)]!
}
@@ -36,25 +38,79 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
}
export default tool({
description: `Use this tool to assign a GitHub issue.
description: `Use this tool to assign and/or label a GitHub issue.
Provide the team that should own the issue. This tool picks a random assignee from that team and does not apply labels.`,
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
args: {
team: tool.schema
.enum(Object.keys(TEAM) as [keyof typeof TEAM, ...(keyof typeof TEAM)[]])
.describe("The owning team"),
assignee: tool.schema
.enum(ASSIGNEES as [string, ...string[]])
.describe("The username of the assignee")
.default("rekram1-node"),
labels: tool.schema
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
.describe("The labels(s) to add to the issue")
.default([]),
},
async execute(args) {
const issue = getIssueNumber()
const owner = "anomalyco"
const repo = "opencode"
const assignee = pick(TEAM[args.team])
const results: string[] = []
let labels = [...new Set(args.labels.map((x) => (x === "desktop" ? "web" : x)))]
const web = labels.includes("web")
const text = `${process.env.ISSUE_TITLE ?? ""}\n${process.env.ISSUE_BODY ?? ""}`.toLowerCase()
const zen = /\bzen\b/.test(text) || text.includes("opencode black")
const nix = /\bnix(os)?\b/.test(text)
if (labels.includes("nix") && !nix) {
labels = labels.filter((x) => x !== "nix")
results.push("Dropped label: nix (issue does not mention nix)")
}
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
if (labels.includes("zen") && !zen) {
throw new Error("Only add the zen label when issue title/body contains 'zen'")
}
if (web && !nix && !(TEAM.desktop as readonly string[]).includes(assignee)) {
throw new Error("Web issues must be assigned to adamdotdevin, iamdavidhill, Brendonovich, or nexxeln")
}
if ((TEAM.zen as readonly string[]).includes(assignee) && !labels.includes("zen")) {
throw new Error("Only zen issues should be assigned to fwang or MrMushrooooom")
}
if (assignee === "Hona" && !labels.includes("windows")) {
throw new Error("Only windows issues should be assigned to Hona")
}
if (assignee === "R44VC0RP" && !labels.includes("docs")) {
throw new Error("Only docs issues should be assigned to R44VC0RP")
}
if (assignee === "kommander" && !labels.includes("opentui")) {
throw new Error("Only opentui issues should be assigned to kommander")
}
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, {
method: "POST",
body: JSON.stringify({ assignees: [assignee] }),
})
results.push(`Assigned @${assignee} to issue #${issue}`)
return `Assigned @${assignee} from ${args.team} to issue #${issue}`
if (labels.length > 0) {
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, {
method: "POST",
body: JSON.stringify({ labels }),
})
results.push(`Added labels: ${labels.join(", ")}`)
}
return results.join("\n")
},
})

View File

@@ -132,7 +132,7 @@ It's very similar to Claude Code in terms of capability. Here are the key differ
- 100% open source
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important.
- Built-in opt-in LSP support
- Out-of-the-box LSP support
- A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
- A client/server architecture. This, for example, can allow OpenCode to run on your computer while you drive it remotely from a mobile app, meaning that the TUI frontend is just one of the possible clients.

View File

@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.14.33",
"version": "1.14.32",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -85,7 +85,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.14.33",
"version": "1.14.32",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -119,7 +119,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.14.33",
"version": "1.14.32",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -146,7 +146,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.14.33",
"version": "1.14.32",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -170,7 +170,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.14.33",
"version": "1.14.32",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -194,7 +194,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.14.33",
"version": "1.14.32",
"bin": {
"opencode": "./bin/opencode",
},
@@ -228,7 +228,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.14.33",
"version": "1.14.32",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -263,7 +263,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.14.33",
"version": "1.14.32",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -309,7 +309,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.14.33",
"version": "1.14.32",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -338,7 +338,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.14.33",
"version": "1.14.32",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -354,7 +354,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.14.33",
"version": "1.14.32",
"bin": {
"opencode": "./bin/opencode",
},
@@ -496,7 +496,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.14.33",
"version": "1.14.32",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -531,7 +531,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.14.33",
"version": "1.14.32",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -546,7 +546,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.14.33",
"version": "1.14.32",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -581,7 +581,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.14.33",
"version": "1.14.32",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -630,7 +630,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.14.33",
"version": "1.14.32",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -715,7 +715,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.59",
"effect": "4.0.0-beta.57",
"fuzzysort": "3.1.0",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
@@ -3078,7 +3078,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-beta.59", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA=="],
"effect": ["effect@4.0.0-beta.57", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-rg32VgXnLKaPRs9tbRDaZ5jxmzNY7ojXt85gSHGUTwdlbWH5Ik+OCUY2q14TXliygPGoHwCAvNWS4bQJOqf00g=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-9wTDLZsuGjkWyVOb6AG2VRYPiaSj/lnXwVkSwNeDcns=",
"aarch64-linux": "sha256-gmKlL2fQxY8bo+//8m9e1TNYJK3RXa4i8xsgtd046bc=",
"aarch64-darwin": "sha256-ENSJK+7rZi3m342mjtGg9N0P6zWEypXMpI7QdFMydbc=",
"x86_64-darwin": "sha256-gkxCxGh5dlwj03vZdz20pbiAwFEDpAlu/5iU8cwZOGI="
"x86_64-linux": "sha256-OtyfKTBEHsJpjzAjN9vCR0PzGzdK6CDHdyU7eZ6Gl1s=",
"aarch64-linux": "sha256-3eHJs3S/+uDUPAouWPsdBOlEvAOhOYx5bJzahL0tAJk=",
"aarch64-darwin": "sha256-rFXzrkhPVb3yM20J8R8m7GqroNNk1vAEz+o/Ks+iAI4=",
"x86_64-darwin": "sha256-lb1IGgbpxg723Qxj2WVPkxKUUmyOIsFOAhA5LoZ8GwY="
}
}

View File

@@ -55,6 +55,7 @@ stdenvNoCC.mkDerivation {
--filter './packages/opencode' \
--filter './packages/desktop' \
--filter './packages/app' \
--filter './packages/shared' \
--frozen-lockfile \
--ignore-scripts \
--no-progress

View File

@@ -53,7 +53,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.59",
"effect": "4.0.0-beta.57",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.14.33",
"version": "1.14.32",
"description": "",
"type": "module",
"exports": {

View File

@@ -42,7 +42,6 @@ import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { SettingsProvider } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal"
import { WslServersProvider } from "@/context/wsl-servers"
import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
@@ -75,7 +74,7 @@ declare global {
__OPENCODE__?: {
updaterEnabled?: boolean
deepLinks?: string[]
activeServer?: string
wsl?: boolean
}
api?: {
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
@@ -157,13 +156,11 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
}}
>
<QueryProvider>
<WslServersProvider>
<DialogProvider>
<MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
</DialogProvider>
</WslServersProvider>
<DialogProvider>
<MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
</DialogProvider>
</QueryProvider>
</ErrorBoundary>
</UiI18nBridge>
@@ -286,11 +283,11 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key:
)
}
function ServerKey(props: { children: (key: ServerConnection.Key) => JSX.Element }) {
function ServerKey(props: ParentProps) {
const server = useServer()
return (
<Show when={server.key} keyed>
{(key) => props.children(key)}
{props.children}
</Show>
)
}
@@ -310,24 +307,22 @@ export function AppInterface(props: {
>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<ServerKey>
{() => (
<QueryProvider>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={SessionIndexRoute} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Dynamic>
</GlobalSyncProvider>
</GlobalSDKProvider>
</QueryProvider>
)}
<QueryProvider>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={SessionIndexRoute} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Dynamic>
</GlobalSyncProvider>
</GlobalSDKProvider>
</QueryProvider>
</ServerKey>
</ConnectionGate>
</ServerProvider>

View File

@@ -8,22 +8,17 @@ import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast"
import { batch, createEffect, createMemo, createResource, For, onCleanup, Show, untrack } from "solid-js"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { DialogWslServer } from "@/components/dialog-wsl-server"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useWslServers } from "@/context/wsl-servers"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
const DEFAULT_USERNAME = "opencode"
interface DialogSelectServerProps {
onNavigateHome?: () => void
}
interface ServerFormProps {
value: string
name: string
@@ -32,6 +27,7 @@ interface ServerFormProps {
placeholder: string
busy: boolean
error: string
status: boolean | undefined
onChange: (value: string) => void
onNameChange: (value: string) => void
onUsernameChange: (value: string) => void
@@ -48,17 +44,15 @@ function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown
})
}
function isWslSidecar(conn: ServerConnection.Any): conn is ServerConnection.Sidecar & { variant: "wsl" } {
return conn.type === "sidecar" && conn.variant === "wsl"
}
function useDefaultServer() {
const language = useLanguage()
const platform = usePlatform()
const [defaultKey, defaultActions] = createResource(
const [defaultKey, defaultUrlActions] = createResource(
async () => {
try {
return (await platform.getDefaultServer?.()) ?? null
const key = await platform.getDefaultServer?.()
if (!key) return null
return key
} catch (err) {
showRequestError(language, err)
return null
@@ -66,18 +60,52 @@ function useDefaultServer() {
},
{ initialValue: null },
)
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
const setDefault = async (key: ServerConnection.Key | null) => {
try {
await platform.setDefaultServer?.(key)
defaultActions.mutate(key)
defaultUrlActions.mutate(key)
} catch (err) {
showRequestError(language, err)
}
}
return { defaultKey, canDefault, setDefault }
}
function useServerPreview() {
const checkServerHealth = useCheckServerHealth()
const looksComplete = (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) return false
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
if (!host) return false
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
return host.includes(".") || host.includes(":")
}
const previewStatus = async (
value: string,
username: string,
password: string,
setStatus: (value: boolean | undefined) => void,
) => {
setStatus(undefined)
if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value)
if (!normalized) return
const http: ServerConnection.HttpBase = { url: normalized }
if (username) http.username = username
if (password) http.password = password
const result = await checkServerHealth(http)
setStatus(result.healthy)
}
return { previewStatus }
}
function ServerForm(props: ServerFormProps) {
const language = useLanguage()
const keyDown = (event: KeyboardEvent) => {
@@ -143,18 +171,15 @@ function ServerForm(props: ServerFormProps) {
)
}
export function DialogSelectServer(props: DialogSelectServerProps = {}) {
export function DialogSelectServer() {
const navigate = useNavigate()
const dialog = useDialog()
const server = useServer()
const platform = usePlatform()
const language = useLanguage()
const wslServers = useWslServers()
const defaultServer = useDefaultServer()
const { defaultKey, canDefault, setDefault } = useDefaultServer()
const { previewStatus } = useServerPreview()
const checkServerHealth = useCheckServerHealth()
let disposed = false
onCleanup(() => {
disposed = true
})
const [store, setStore] = createStore({
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
addServer: {
@@ -164,9 +189,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
password: "",
error: "",
showForm: false,
},
addWsl: {
showWizard: false,
status: undefined as boolean | undefined,
},
editServer: {
id: undefined as string | undefined,
@@ -175,6 +198,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
username: "",
password: "",
error: "",
status: undefined as boolean | undefined,
},
})
@@ -186,6 +210,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
password: "",
error: "",
showForm: false,
status: undefined,
})
}
const resetEdit = () => {
@@ -196,6 +221,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
username: "",
password: "",
error: "",
status: undefined,
})
}
@@ -268,31 +294,6 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
},
}))
const removeWslMutation = useMutation(() => ({
mutationFn: async (key: ServerConnection.Key) => {
await platform.wslServers?.removeServer(key)
return key
},
onSuccess: async (key) => {
server.remove(key)
},
onError: (err) => showRequestError(language, err),
}))
const retryWslMutation = useMutation(() => ({
mutationFn: async (key: ServerConnection.Key) => {
await platform.wslServers?.startServer(key)
},
onError: (err) => showRequestError(language, err),
}))
const updateWslMutation = useMutation(() => ({
mutationFn: async (distro: string) => {
await platform.wslServers?.installOpencode(distro)
},
onError: (err) => showRequestError(language, err),
}))
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
const active = server.key
const newConn = server.add(next)
@@ -311,32 +312,6 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
})
const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0])
const wslState = () => wslServers.data
const healthPollKey = createMemo(() =>
items()
.map((conn) =>
[ServerConnection.key(conn), conn.http.url, conn.http.username ?? "", conn.http.password ?? ""].join("\n"),
)
.join("\n\n"),
)
const health = (key: ServerConnection.Key) => store.status[key]
const wslRuntime = (conn: ServerConnection.Any) => {
if (!isWslSidecar(conn)) return
return wslState()?.servers.find((item) => item.config.id === ServerConnection.key(conn))?.runtime
}
const nonReadyWslServers = createMemo(() =>
(wslState()?.servers ?? []).filter((item) => item.runtime.kind !== "ready"),
)
const canRetryWsl = (conn: ServerConnection.Any) => {
const runtime = wslRuntime(conn)
return runtime?.kind === "failed" || runtime?.kind === "stopped"
}
const canRetryWslRuntime = (kind: string) => kind === "failed" || kind === "stopped"
const wslRuntimeLabel = (kind: string) => {
if (kind === "starting") return "Starting"
if (kind === "failed") return "Failed"
return "Stopped"
}
const sortedItems = createMemo(() => {
const list = items()
@@ -351,7 +326,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff = rank(health(ServerConnection.key(a))) - rank(health(ServerConnection.key(b)))
const diff = rank(store.status[ServerConnection.key(a)]) - rank(store.status[ServerConnection.key(b)])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
@@ -359,60 +334,39 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
async function refreshHealth() {
const results: Record<ServerConnection.Key, ServerHealth> = {}
const list = untrack(items)
await Promise.all(
list.map(async (conn) => {
items().map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
}),
)
if (disposed) return
setStore("status", reconcile(results))
}
createEffect(() => {
healthPollKey()
items()
void refreshHealth()
const interval = setInterval(refreshHealth, 10_000)
onCleanup(() => clearInterval(interval))
})
const wslCheck = (conn: ServerConnection.Any) => {
if (!isWslSidecar(conn)) return null
return wslState()?.opencodeChecks[conn.distro] ?? null
}
async function select(conn: ServerConnection.Any, persist?: boolean) {
if (!persist && health(ServerConnection.key(conn))?.healthy === false) return
const nextKey = ServerConnection.key(conn)
const changed = server.key !== nextKey
const navigateHome = () => props.onNavigateHome?.()
const apply = () => {
dialog.close()
if (persist && conn.type === "http") {
server.add(conn)
navigateHome()
return
}
batch(() => {
navigateHome()
server.setActive(nextKey)
})
}
if (!changed) {
await apply()
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
dialog.close()
if (persist && conn.type === "http") {
server.add(conn)
navigate("/")
return
}
apply()
navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
}
const handleAddChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { url: value, error: "" })
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
)
}
const handleAddNameChange = (value: string) => {
@@ -423,16 +377,25 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
const handleAddUsernameChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { username: value, error: "" })
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
)
}
const handleAddPasswordChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { password: value, error: "" })
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
setStore("addServer", { status: next }),
)
}
const handleEditChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { value, error: "" })
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
)
}
const handleEditNameChange = (value: string) => {
@@ -443,15 +406,20 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
const handleEditUsernameChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { username: value, error: "" })
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
)
}
const handleEditPasswordChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { password: value, error: "" })
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
setStore("editServer", { status: next }),
)
}
const mode = createMemo<"list" | "add-wsl" | "add" | "edit">(() => {
if (store.addWsl.showWizard) return "add-wsl"
const mode = createMemo<"list" | "add" | "edit">(() => {
if (store.editServer.id) return "edit"
if (store.addServer.showForm) return "add"
return "list"
@@ -465,11 +433,9 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
const resetForm = () => {
resetAdd()
resetEdit()
setStore("addWsl", "showWizard", false)
}
const startAdd = () => {
setStore("addWsl", "showWizard", false)
resetEdit()
setStore("addServer", {
showForm: true,
@@ -478,11 +444,11 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
username: DEFAULT_USERNAME,
password: "",
error: "",
status: undefined,
})
}
const startEdit = (conn: ServerConnection.Http) => {
setStore("addWsl", "showWizard", false)
resetAdd()
setStore("editServer", {
id: conn.http.url,
@@ -491,22 +457,10 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
username: conn.http.username ?? "",
password: conn.http.password ?? "",
error: "",
status: store.status[ServerConnection.key(conn)]?.healthy,
})
}
const startAddWsl = () => {
resetAdd()
resetEdit()
setStore("addWsl", "showWizard", true)
}
const handleAddedWsl = async (distro: string) => {
const key = ServerConnection.Key.make(`wsl:${distro}`)
setStore("addWsl", "showWizard", false)
const conn = items().find((item) => ServerConnection.key(item) === key)
if (conn) await select(conn)
}
const submitForm = () => {
if (mode() === "add") {
if (addMutation.isPending) return
@@ -523,22 +477,14 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
const isFormMode = createMemo(() => mode() !== "list")
const isAddMode = createMemo(() => mode() === "add")
const isAddWslMode = createMemo(() => mode() === "add-wsl")
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
const canAddWsl = createMemo(() => !!platform.wslServers && platform.os === "windows")
const formTitle = createMemo(() => {
if (!isFormMode()) return language.t("dialog.server.title")
return (
<div class="flex items-center gap-2 -ml-2">
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
<span>
{isAddWslMode()
? "Add WSL server"
: isAddMode()
? language.t("dialog.server.add.title")
: language.t("dialog.server.edit.title")}
</span>
<span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
</div>
)
})
@@ -549,107 +495,37 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
resetEdit()
})
async function handleRemove(key: ServerConnection.Key) {
server.remove(key)
if (defaultServer.defaultKey() === key) await defaultServer.setDefault(null)
async function handleRemove(url: ServerConnection.Key) {
server.remove(url)
if ((await platform.getDefaultServer?.()) === url) {
void platform.setDefaultServer?.(null)
}
}
return (
<Dialog
title={formTitle()}
fit={isAddWslMode()}
class={isAddWslMode() ? "[&_[data-slot=dialog-body]]:flex-none [&_[data-slot=dialog-body]]:overflow-visible" : undefined}
>
<div class={isAddWslMode() ? "flex flex-col gap-2" : "flex flex-1 min-h-0 flex-col gap-2"}>
<Dialog title={formTitle()}>
<div class="flex flex-1 min-h-0 flex-col gap-2">
<Show
when={!isFormMode()}
fallback={
<Show
when={isAddWslMode()}
fallback={
<ServerForm
value={isAddMode() ? store.addServer.url : store.editServer.value}
name={isAddMode() ? store.addServer.name : store.editServer.name}
username={isAddMode() ? store.addServer.username : store.editServer.username}
password={isAddMode() ? store.addServer.password : store.editServer.password}
placeholder={language.t("dialog.server.add.placeholder")}
busy={formBusy()}
error={isAddMode() ? store.addServer.error : store.editServer.error}
onChange={isAddMode() ? handleAddChange : handleEditChange}
onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
onSubmit={submitForm}
onBack={resetForm}
/>
}
>
<DialogWslServer onAdded={handleAddedWsl} />
</Show>
<ServerForm
value={isAddMode() ? store.addServer.url : store.editServer.value}
name={isAddMode() ? store.addServer.name : store.editServer.name}
username={isAddMode() ? store.addServer.username : store.editServer.username}
password={isAddMode() ? store.addServer.password : store.editServer.password}
placeholder={language.t("dialog.server.add.placeholder")}
busy={formBusy()}
error={isAddMode() ? store.addServer.error : store.editServer.error}
status={isAddMode() ? store.addServer.status : store.editServer.status}
onChange={isAddMode() ? handleAddChange : handleEditChange}
onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
onSubmit={submitForm}
onBack={resetForm}
/>
}
>
<Show when={nonReadyWslServers().length > 0}>
<div class="px-5">
<div class="bg-surface-base rounded-md overflow-hidden">
<For each={nonReadyWslServers()}>
{(item) => {
const key = ServerConnection.Key.make(item.config.id)
const retryable = () => canRetryWslRuntime(item.runtime.kind)
return (
<div class="min-h-14 p-3 flex items-center gap-3 border-b border-border-weak-base last:border-b-0">
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-critical-base": item.runtime.kind === "failed",
"bg-border-weak-base": item.runtime.kind !== "failed",
}}
/>
<div class="flex items-center gap-2 min-w-0 flex-1">
<span class="text-14-medium text-text-base truncate">{item.config.distro}</span>
<span class="text-11-regular text-text-weak border border-border-weak-base bg-surface-base px-1.5 py-0.5 rounded-md shrink-0">
WSL
</span>
<span class="text-12-regular text-text-weak truncate">
{wslRuntimeLabel(item.runtime.kind)}
</span>
</div>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<Show when={retryable()}>
<DropdownMenu.Item onSelect={() => retryWslMutation.mutate(key)}>
<DropdownMenu.ItemLabel>Retry start</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={retryable()}>
<DropdownMenu.Separator />
</Show>
<DropdownMenu.Item
onSelect={() => removeWslMutation.mutate(key)}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.delete")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
)
}}
</For>
</div>
</div>
</Show>
<List
search={{
placeholder: language.t("dialog.server.search.placeholder"),
@@ -658,7 +534,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
noInitialSelection
emptyMessage={language.t("dialog.server.empty")}
items={sortedItems}
key={(x) => ServerConnection.key(x)}
key={(x) => x.http.url}
onSelect={(x) => {
if (x) void select(x)
}}
@@ -667,35 +543,18 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
>
{(i) => {
const key = ServerConnection.key(i)
const wsl = isWslSidecar(i)
const wslDistro = wsl ? i.distro : undefined
const blocked = () => health(key)?.healthy === false
const canChangeDefault = () => defaultServer.canDefault() && i.type === "http"
const canRemove = () => i.type === "http" || wsl
const opencodeAction = () => {
const check = wslCheck(i)
if (!check) return null
if (!check.resolvedPath) return "Install OpenCode"
if (check.matchesDesktop === false) return "Update OpenCode"
return null
}
const updating = () => {
const job = wslState()?.job
return job?.kind === "install-opencode" && job.distro === wslDistro
}
return (
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
<div class="flex flex-col h-full items-start w-5">
<ServerHealthIndicator health={health(key)} />
<ServerHealthIndicator health={store.status[key]} />
</div>
<ServerRow
conn={i}
dimmed={blocked()}
status={health(key)}
version={wslCheck(i)?.version ?? undefined}
dimmed={store.status[key]?.healthy === false}
status={store.status[key]}
class="flex items-center gap-3 min-w-0 flex-1"
badge={
<Show when={defaultServer.defaultKey() === ServerConnection.key(i)}>
<Show when={defaultKey() === ServerConnection.key(i)}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
@@ -703,29 +562,12 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
}
showCredentials
/>
<div class="flex items-center justify-center gap-3 pl-4">
<Show when={wsl && opencodeAction()}>
{(label) => (
<Button
variant="secondary"
size="small"
disabled={!!wslState()?.job}
class="shrink-0"
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
onClick={(e: MouseEvent) => {
e.stopPropagation()
if (wslDistro) updateWslMutation.mutate(wslDistro)
}}
>
{updating() ? "Updating OpenCode..." : label()}
</Button>
)}
</Show>
<div class="flex items-center justify-center gap-4 pl-4">
<Show when={ServerConnection.key(current()) === key}>
<Icon name="check" class="h-6" />
</Show>
<Show when={i.type === "http" || i.type === "sidecar"}>
<Show when={i.type === "http"}>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
@@ -737,54 +579,35 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<Show when={i.type === "http"}>
<DropdownMenu.Item
onSelect={() => {
if (i.type !== "http") return
startEdit(i)
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={wsl && canRetryWsl(i)}>
<DropdownMenu.Item onSelect={() => retryWslMutation.mutate(key)}>
<DropdownMenu.ItemLabel>Retry start</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canChangeDefault() && defaultServer.defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => void defaultServer.setDefault(key)}>
<DropdownMenu.Item
onSelect={() => {
if (i.type !== "http") return
startEdit(i)
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canDefault() && defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => setDefault(key)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canChangeDefault() && defaultServer.defaultKey() === key}>
<DropdownMenu.Item onSelect={() => void defaultServer.setDefault(null)}>
<Show when={canDefault() && defaultKey() === key}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canRemove() && (i.type === "http" || canChangeDefault() || canRetryWsl(i))}>
<DropdownMenu.Separator />
</Show>
<Show when={canRemove()}>
<DropdownMenu.Item
onSelect={() => {
if (wsl) {
removeWslMutation.mutate(key)
return
}
void handleRemove(key)
}}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.delete")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
@@ -798,32 +621,17 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
<div class="shrink-0 px-5 pb-5">
<Show
when={!isAddWslMode() && isFormMode()}
when={isFormMode()}
fallback={
<Show when={!isAddWslMode()}>
<div class="flex items-center gap-2">
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={startAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
{language.t("dialog.server.add.button")}
</Button>
<Show when={canAddWsl()}>
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={startAddWsl}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
Add WSL
</Button>
</Show>
</div>
</Show>
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={startAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
{language.t("dialog.server.add.button")}
</Button>
}
>
<Button variant="primary" size="large" onClick={submitForm} disabled={formBusy()} class="px-3 py-1.5">

View File

@@ -1,575 +0,0 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Spinner } from "@opencode-ai/ui/spinner"
import { showToast } from "@opencode-ai/ui/toast"
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useWslServers } from "@/context/wsl-servers"
type WslServerStep = "wsl" | "distro" | "opencode"
const STEPS: WslServerStep[] = ["wsl", "distro", "opencode"]
function isHiddenDistro(name: string) {
return /^docker-desktop(?:-data)?$/i.test(name)
}
interface DialogWslServerProps {
onAdded?: (distro: string) => void | Promise<void>
}
export function DialogWslServer(props: DialogWslServerProps = {}) {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
const wslServers = useWslServers()
const api = platform.wslServers!
const [store, setStore] = createStore({
step: undefined as WslServerStep | undefined,
selectedDistro: null as string | null,
installTarget: undefined as string | undefined,
adding: false,
})
const current = () => wslServers.data
let disposed = false
onCleanup(() => {
disposed = true
})
const busy = createMemo(() => !!current()?.job || store.adding)
const selectedProbe = createMemo(() => {
const distro = store.selectedDistro
if (!distro) return null
return current()?.distroProbes[distro] ?? null
})
const selectedInstalled = createMemo(() => {
const distro = store.selectedDistro
if (!distro) return null
return (current()?.installed ?? []).find((item) => item.name === distro) ?? null
})
const visibleInstalledDistros = createMemo(() =>
(current()?.installed ?? []).filter((item) => !isHiddenDistro(item.name)),
)
const visibleOnlineDistros = createMemo(() => (current()?.online ?? []).filter((item) => !isHiddenDistro(item.name)))
const defaultInstalledDistro = createMemo(() => visibleInstalledDistros().find((item) => item.isDefault) ?? null)
const opencodeCheck = createMemo(() => {
const distro = store.selectedDistro
if (!distro) return null
return current()?.opencodeChecks[distro] ?? null
})
const distroWarningProbe = createMemo(() => {
const probe = selectedProbe()
if (!probe) return null
if (distroReady()) return null
return probe
})
const distroUnavailableMessage = createMemo(() => {
const probe = distroWarningProbe()
const distro = store.selectedDistro
if (!probe || probe.canExecute || !distro) return null
if (!selectedInstalled()) return `${distro} is not installed yet.`
return `Open ${distro} once to finish setup.`
})
const distroMissingTools = createMemo(() => {
const probe = distroWarningProbe()
if (!probe?.canExecute) return null
if (probe.hasBash && probe.hasCurl) return null
return probe
})
const existingServerDistros = createMemo(() => new Set((current()?.servers ?? []).map((item) => item.config.distro)))
const addableInstalledDistros = createMemo(() => {
return visibleInstalledDistros().filter((item) => !existingServerDistros().has(item.name))
})
const installableDistros = createMemo(() => {
const online = visibleOnlineDistros()
const installed = new Set(visibleInstalledDistros().map((item) => item.name))
const hasVersionedUbuntu = online.some((item) => /^Ubuntu-\d/.test(item.name))
return online
.filter((item) => !installed.has(item.name))
.filter((item) => !(item.name === "Ubuntu" && hasVersionedUbuntu))
})
const installTarget = createMemo(() => installableDistros().find((item) => item.name === store.installTarget) ?? null)
const installingDistro = createMemo(() => current()?.job?.kind === "install-distro")
const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
const distroReady = createMemo(() => {
const probe = selectedProbe()
if (!probe || !store.selectedDistro) return false
if (selectedInstalled()?.version === 1) return false
return probe.canExecute && probe.hasBash && probe.hasCurl
})
const opencodeReady = createMemo(() => {
const check = opencodeCheck()
return !!check?.resolvedPath && !check.error
})
const allReady = createMemo(() => wslReady() && distroReady() && opencodeReady())
const addDisabled = createMemo(() => {
const job = current()?.job
if (!job) return store.adding
return store.adding || job.kind !== "probe-opencode"
})
const recommendedStep = createMemo<WslServerStep>(() => {
if (!wslReady()) return "wsl"
if (!distroReady()) return "distro"
return "opencode"
})
// activeStep falls back to recommendedStep when the user hasn't picked one.
// Once the user clicks a step tab we respect their choice rather than snapping
// them back when a probe result updates recommendedStep.
const activeStep = createMemo(() => store.step ?? recommendedStep())
const autoProbe = createMemo(() => {
const state = current()
if (!state || busy()) return null
if (state.pendingRestart) return null
if (!state.runtime) return { key: "runtime", run: () => api.probeRuntime() }
if (!wslReady()) return null
if (!state.installed.length && !state.online.length) {
return { key: "distros", run: () => api.refreshDistros() }
}
const distro = store.selectedDistro
if (distro && !state.distroProbes[distro]) {
return { key: `probe-distro:${distro}`, run: () => api.probeDistro(distro) }
}
if (!distro || !distroReady()) return null
if (!state.opencodeChecks[distro]) {
return { key: `probe-opencode:${distro}`, run: () => api.probeOpencode(distro) }
}
return null
})
let lastAutoProbe: string | null = null
createEffect(() => {
const probe = autoProbe()
if (!probe || probe.key === lastAutoProbe) return
const key = probe.key
lastAutoProbe = key
void (async () => {
try {
await probe.run()
} catch (err) {
if (disposed) return
// Allow the same probe to run again when reactive inputs next change
// (e.g. user reselects a distro). Without this the user would be stuck
// on a transient wsl.exe failure until they pick a different distro.
if (lastAutoProbe === key) lastAutoProbe = null
requestError(language, err)
}
})()
})
createEffect(() => {
const state = current()
const distro = defaultInstalledDistro()
if (!state || !distro || busy()) return
if (store.selectedDistro) return
if (existingServerDistros().has(distro.name)) return
setStore("selectedDistro", distro.name)
})
createEffect(() => {
const distros = installableDistros()
if (!distros.length) {
if (store.installTarget) setStore("installTarget", undefined)
return
}
if (store.installTarget && distros.some((item) => item.name === store.installTarget)) return
setStore("installTarget", distros[0]!.name)
})
const wslMessage = createMemo(() => {
const state = current()
if (!state || state.job?.kind === "runtime") return "Checking WSL..."
if (state.pendingRestart) return "Windows needs a restart to finish installing WSL."
if (state.runtime?.available) return state.runtime.version ?? "WSL is ready."
return state.runtime?.error ?? "WSL is required to continue."
})
const distroMessage = createMemo(() => {
const state = current()
if (!state) return "Checking distros..."
const distro = store.selectedDistro
if (state.job?.kind === "install-distro") return `Installing ${state.job.distro}...`
if (state.job?.kind === "probe-distro") return `Checking ${state.job.distro}...`
if (state.job?.kind === "distros") return "Listing distros..."
if (distroUnavailableMessage()) return distroUnavailableMessage()!
if (selectedProbe() && distroReady()) return `${selectedProbe()!.name} is ready.`
if (distro) return `Finishing setup for ${distro}.`
return "Pick a distro or install one below."
})
const opencodeMessage = createMemo(() => {
const state = current()
if (!state) return "Checking OpenCode..."
const distro = store.selectedDistro
if (state.job?.kind === "probe-opencode" || state.job?.kind === "install-opencode") {
return distro ? `Checking OpenCode in ${distro}...` : "Checking OpenCode..."
}
if (opencodeCheck()?.error) return opencodeCheck()!.error
if (opencodeCheck()?.matchesDesktop === false) {
return distro ? `Update OpenCode in ${distro}.` : "Update OpenCode."
}
if (opencodeReady()) return distro ? `OpenCode is ready in ${distro}.` : "OpenCode is ready."
return distro ? `Install OpenCode in ${distro}.` : "Choose a distro first."
})
const run = async (action: () => Promise<unknown>) => {
try {
await action()
} catch (err) {
requestError(language, err)
}
}
const runSelectedDistro = (action: (distro: string) => Promise<unknown>) => {
const distro = store.selectedDistro
if (!distro) return
void run(() => action(distro))
}
const selectDistro = (name: string) => {
setStore("selectedDistro", name)
setStore("step", undefined)
}
const finish = async () => {
const distro = store.selectedDistro
if (!distro) return
setStore("adding", true)
try {
await api.addServer(distro)
if (props.onAdded) {
await props.onAdded(distro)
} else {
dialog.close()
}
} catch (err) {
requestError(language, err)
} finally {
setStore("adding", false)
}
}
const steps = createMemo(() => {
const active = activeStep()
const activeIndex = STEPS.indexOf(active)
const recommendedIndex = STEPS.indexOf(recommendedStep())
return STEPS.map((step) => {
const index = STEPS.indexOf(step)
return {
step,
title: step === "wsl" ? "WSL" : step === "distro" ? "Choose distro" : "OpenCode",
state:
active === step
? "current"
: step === "wsl"
? wslReady()
? "done"
: "warning"
: step === "distro"
? distroReady()
? "done"
: index > activeIndex
? "locked"
: "warning"
: opencodeCheck()?.matchesDesktop === false
? "warning"
: opencodeReady()
? "done"
: index > activeIndex
? "locked"
: "warning",
locked: index > recommendedIndex,
}
})
})
const loadError = createMemo(() => {
const error = wslServers.error
if (!error) return "Failed to load WSL state."
return error instanceof Error ? error.message : String(error)
})
return (
<div class="px-5 pb-5 flex flex-col gap-4">
<Show when={!wslServers.isPending} fallback={<div class="px-1 py-6 text-14-regular text-text-weak">Loading...</div>}>
<Show when={!wslServers.isError} fallback={<div class="px-1 py-6 text-14-regular text-text-weak">{loadError()}</div>}>
<div class="flex gap-2 pb-1">
<For each={steps()}>
{(item) => (
<button
type="button"
class="basis-0 flex-1 min-w-0 rounded-md border px-3 py-2 text-left transition-colors"
classList={{
"border-border-strong-base bg-surface-base-hover": item.state === "current",
"border-icon-success-base/40 bg-surface-base": item.state === "done",
"border-border-weak-base bg-background-base opacity-60": item.state === "locked",
"border-icon-warning-base/40 bg-surface-base": item.state === "warning",
}}
disabled={item.locked}
onClick={() => setStore("step", item.step)}
>
<div class="text-13-medium text-text-strong">{item.title}</div>
</button>
)}
</For>
</div>
<Switch>
<Match when={activeStep() === "wsl"}>
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<div class="text-14-medium text-text-strong">WSL</div>
<Show when={current()?.runtime && !wslReady() && !current()?.pendingRestart}>
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => void run(() => api.installWsl())}
>
Install WSL
</Button>
</Show>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{wslMessage()}</div>
<Show when={current()?.pendingRestart}>
<div class="rounded-md border border-border-weak-base px-3 py-3 flex items-center justify-between gap-3">
<div class="text-12-regular text-text-warning-base">Windows restart required.</div>
<Button variant="secondary" size="large" onClick={() => void platform.restart()}>
Relaunch OpenCode
</Button>
</div>
</Show>
<div class="flex items-center justify-end">
<Button variant="secondary" size="large" disabled={busy() || !wslReady()} onClick={() => setStore("step", "distro")}>
Next
</Button>
</div>
</div>
</Match>
<Match when={activeStep() === "distro"}>
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<div class="text-14-medium text-text-strong">Choose a distro</div>
<Show when={store.selectedDistro}>
<Button
variant="ghost"
size="small"
disabled={busy()}
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
>
Refresh
</Button>
</Show>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{distroMessage()}</div>
<div class="flex flex-col gap-2">
<Show
when={addableInstalledDistros().length > 0}
fallback={
<div class="text-12-regular text-text-weak">
{visibleInstalledDistros().length
? "All installed distros are already added."
: current()?.runtime?.available
? "No distros detected yet."
: "Checking distros..."}
</div>
}
>
<For each={addableInstalledDistros()}>
{(item) => (
<button
type="button"
class="rounded-md border border-border-weak-base px-3 py-2 text-left transition-colors"
classList={{ "bg-surface-raised-base": store.selectedDistro === item.name }}
onClick={() => selectDistro(item.name)}
>
<div class="text-13-medium text-text-strong">{item.name}</div>
<Show when={item.isDefault}>
<div class="text-12-regular text-text-weak">Default</div>
</Show>
</button>
)}
</For>
</Show>
</div>
<Show when={installableDistros().length > 0}>
<div class="rounded-md border border-border-weak-base p-2 flex flex-col gap-2">
<div class="px-1 flex items-center justify-between gap-3">
<div class="text-12-medium text-text-weak">Install</div>
<div class="flex items-center gap-2 shrink-0">
<Show when={installingDistro()}>
<Spinner class="h-4 w-4 text-icon-info-base shrink-0" />
</Show>
<Button
variant="secondary"
size="small"
disabled={busy() || !installTarget()}
onClick={() => void run(() => api.installDistro(installTarget()!.name))}
>
{installingDistro() ? "Installing..." : "Install"}
</Button>
</div>
</div>
<div
role="radiogroup"
aria-label="Install distro"
class="max-h-52 overflow-y-auto rounded-md bg-background-base"
>
<For each={installableDistros()}>
{(item) => {
const selected = () => store.installTarget === item.name
return (
<button
type="button"
role="radio"
aria-checked={selected()}
disabled={busy()}
class="w-full px-3 py-2 flex items-center gap-3 text-left border-b border-border-weak-base last:border-b-0 transition-colors"
classList={{
"bg-surface-raised-base": selected(),
"hover:bg-surface-base": !selected(),
}}
onClick={() => setStore("installTarget", item.name)}
>
<div
class="mt-0.5 h-4 w-4 rounded-full border border-border-strong-base flex items-center justify-center shrink-0"
classList={{ "border-text-strong": selected() }}
>
<div class="h-2 w-2 rounded-full bg-text-strong" classList={{ hidden: !selected() }} />
</div>
<div class="min-w-0 flex-1 text-13-medium text-text-strong truncate">{item.label}</div>
</button>
)
}}
</For>
</div>
</div>
</Show>
<Show
when={
selectedInstalled()?.version === 1 ||
distroUnavailableMessage() ||
distroMissingTools()
}
>
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
<Show when={selectedInstalled()?.version === 1}>
<div class="text-12-regular text-text-warning-base">WSL 2 is required.</div>
</Show>
<Show when={distroUnavailableMessage()}>
{(message) => <div class="text-12-regular text-text-warning-base">{message()}</div>}
</Show>
<Show when={distroMissingTools()}>
<div class="text-12-regular text-text-warning-base">This distro needs bash and curl.</div>
</Show>
</div>
</Show>
<div class="flex items-center gap-2">
<Button
variant="secondary"
size="large"
disabled={busy() || !selectedInstalled()}
onClick={() => runSelectedDistro((distro) => api.openTerminal(distro))}
>
Open terminal
</Button>
<Button
variant="ghost"
size="large"
disabled={busy() || !store.selectedDistro}
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
>
Refresh
</Button>
</div>
<div class="flex items-center justify-end">
<Button
variant="secondary"
size="large"
disabled={busy() || !store.selectedDistro || !distroReady()}
onClick={() => setStore("step", "opencode")}
>
Next
</Button>
</div>
</div>
</Match>
<Match when={activeStep() === "opencode"}>
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<div class="text-14-medium text-text-strong">OpenCode</div>
<div class="flex items-center gap-2">
<Show when={store.selectedDistro}>
<Button
variant="ghost"
size="large"
disabled={busy()}
onClick={() => runSelectedDistro((distro) => api.probeOpencode(distro))}
>
Refresh
</Button>
</Show>
<Show when={!opencodeReady() || opencodeCheck()?.matchesDesktop === false}>
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => runSelectedDistro((distro) => api.installOpencode(distro))}
>
{opencodeCheck()?.resolvedPath ? "Update OpenCode" : "Install OpenCode"}
</Button>
</Show>
</div>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{opencodeMessage()}</div>
<Show when={opencodeCheck()?.matchesDesktop === false ? opencodeCheck() : null}>
{(check) => (
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
<div class="text-12-regular text-text-weak">Path: {check().resolvedPath ?? "not found"}</div>
<div class="text-12-regular text-text-weak">
Version: {check().version ?? "unknown"}
<Show when={check().expectedVersion}>
{(expected) => <span>{` · desktop ${expected()}`}</span>}
</Show>
</div>
<div class="text-12-regular text-text-warning-base">
Installed version does not match the desktop app version.
</div>
</div>
)}
</Show>
</div>
</Match>
</Switch>
<Show when={activeStep() === "opencode" && allReady() && store.selectedDistro}>
<div class="flex items-center justify-end gap-2">
<Button variant="ghost" size="large" disabled={store.adding} onClick={() => dialog.close()}>
Cancel
</Button>
<Button variant="primary" size="large" disabled={addDisabled()} onClick={() => void finish()}>
{store.adding ? "Adding..." : "Add WSL server"}
</Button>
</div>
</Show>
</Show>
</Show>
</div>
)
}
function requestError(language: ReturnType<typeof useLanguage>, err: unknown) {
console.error("WSL servers request failed", err instanceof Error ? (err.stack ?? err.message) : String(err))
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}

View File

@@ -17,7 +17,6 @@ import type { ServerHealth } from "@/utils/server-health"
interface ServerRowProps extends ParentProps {
conn: ServerConnection.Any
status?: ServerHealth
version?: string
class?: string
nameClass?: string
versionClass?: string
@@ -32,8 +31,6 @@ export function ServerRow(props: ServerRowProps) {
let nameRef: HTMLSpanElement | undefined
let versionRef: HTMLSpanElement | undefined
const name = createMemo(() => serverName(props.conn))
const isWsl = createMemo(() => props.conn.type === "sidecar" && props.conn.variant === "wsl")
const version = createMemo(() => props.version ?? props.status?.version)
const check = () => {
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
@@ -44,7 +41,7 @@ export function ServerRow(props: ServerRowProps) {
createEffect(() => {
name()
props.conn.http.url
version()
props.status?.version
queueMicrotask(check)
})
@@ -57,11 +54,8 @@ export function ServerRow(props: ServerRowProps) {
const tooltipValue = () => (
<span class="flex items-center gap-2">
<span>{serverName(props.conn, true)}</span>
<Show when={isWsl()}>
<span class="text-text-invert-weak">WSL</span>
</Show>
<Show when={version()}>
<span class="text-text-invert-weak">v{version()}</span>
<Show when={props.status?.version}>
<span class="text-text-invert-weak">v{props.status?.version}</span>
</Show>
</span>
)
@@ -82,20 +76,15 @@ export function ServerRow(props: ServerRowProps) {
<span ref={nameRef} class={`${props.nameClass ?? "truncate"} min-w-0`}>
{name()}
</span>
<Show when={isWsl()}>
<span class="text-11-regular text-text-weak border border-border-weak-base bg-surface-base px-1.5 py-0.5 rounded-md shrink-0">
WSL
</span>
</Show>
<Show
when={badge()}
fallback={
<Show when={version()}>
<Show when={props.status?.version}>
<span
ref={versionRef}
class={`${props.versionClass ?? "text-text-weak text-14-regular truncate"} min-w-0`}
>
v{version()}
v{props.status?.version}
</span>
</Show>
}

View File

@@ -5,7 +5,7 @@ import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useMutation, useQueryClient } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast"
import { useLocation, useNavigate } from "@solidjs/router"
import { useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
@@ -156,14 +156,13 @@ const useMcpToggleMutation = () => {
}))
}
export function StatusPopoverBody(props: { shown: Accessor<boolean>; close?: () => void }) {
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const sync = useSync()
const server = useServer()
const platform = usePlatform()
const dialog = useDialog()
const language = useLanguage()
const navigate = useNavigate()
const location = useLocation()
const fail = (err: unknown) => {
showToast({
@@ -252,16 +251,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean>; close?: ()
aria-disabled={blocked()}
onClick={() => {
if (blocked()) return
props.close?.()
navigate("/")
const activate = () => {
if (location.pathname !== "/") {
setTimeout(activate, 16)
return
}
setTimeout(() => server.setActive(key), 0)
}
setTimeout(activate, 0)
queueMicrotask(() => server.setActive(key))
}}
>
<ServerHealthIndicator health={health[key]} />

View File

@@ -58,7 +58,7 @@ export function StatusPopover() {
<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />
}
>
<Body shown={shown} close={() => setShown(false)} />
<Body shown={shown} />
</Suspense>
</Show>
</Popover>

View File

@@ -15,7 +15,6 @@ import { terminalFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer"
import { terminalWebSocketURL } from "@/utils/terminal-websocket-url"
const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
@@ -68,6 +67,13 @@ const debugTerminal = (...values: unknown[]) => {
console.debug("[terminal]", ...values)
}
const errorName = (err: unknown) => {
if (!err || typeof err !== "object") return
if (!("name" in err)) return
const errorName = err.name
return typeof errorName === "string" ? errorName : undefined
}
const useTerminalUiBindings = (input: {
container: HTMLDivElement
term: Term
@@ -472,34 +478,14 @@ export const Terminal = (props: TerminalProps) => {
const gone = () =>
client.pty
.get({ ptyID: id }, { throwOnError: false })
.then((result) => result.response.status === 404)
.get({ ptyID: id })
.then(() => false)
.catch((err) => {
if (errorName(err) === "NotFoundError") return true
debugTerminal("failed to inspect terminal session", err)
return false
})
const connectToken = async () => {
const result = await client.pty
.connectToken(
{ ptyID: id, directory },
{
throwOnError: false,
headers: { "x-opencode-ticket": "1" },
},
)
.catch((err: unknown) => {
if (err instanceof Error && err.message.includes("Request is not supported")) return
throw err
})
if (!result) return
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
if (result.response.status === 404 || result.response.status === 405) return
if (result.response.status === 403)
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
throw new Error(`PTY connect ticket failed with ${result.response.status}`)
}
const retry = (err: unknown) => {
if (disposed) return
if (reconn !== undefined) return
@@ -519,30 +505,22 @@ export const Terminal = (props: TerminalProps) => {
}, ms)
}
const open = async () => {
const open = () => {
if (disposed) return
drop?.()
const ticket = await connectToken().catch((err) => {
fail(err)
return undefined
})
if (once.value) return
if (disposed) return
const next = new URL(url + `/pty/${id}/connect`)
next.searchParams.set("directory", directory)
next.searchParams.set("cursor", String(seek))
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
if (!sameOrigin && password) {
next.searchParams.set("auth_token", btoa(`${username}:${password}`))
// For same-origin requests, let the browser reuse the page's existing auth.
next.username = username
next.password = password
}
const socket = new WebSocket(
terminalWebSocketURL({
url,
id,
directory,
cursor: seek,
ticket,
sameOrigin,
username,
password,
authToken: server.current?.type === "http" ? server.current.authToken : false,
}),
)
const socket = new WebSocket(next)
socket.binaryType = "arraybuffer"
ws = socket

View File

@@ -371,7 +371,11 @@ function createGlobalSync() {
onCleanup(() => {
queue.dispose()
})
onCleanup(children.disposeAll)
onCleanup(() => {
for (const directory of Object.keys(children.children)) {
children.disposeDirectory(directoryKey(directory))
}
})
onMount(() => {
if (typeof requestAnimationFrame === "function") {

View File

@@ -92,22 +92,6 @@ export function createChildStoreManager(input: {
})
}
function disposeChild(key: DirectoryKey) {
const dispose = disposers.get(key)
if (!key || !children[key]) return false
vcsCache.delete(key)
metaCache.delete(key)
iconCache.delete(key)
lifecycle.delete(key)
disposers.delete(key)
delete children[key]
input.onDispose(key)
if (dispose) {
dispose()
}
return true
}
function disposeDirectory(directory: DirectoryKey) {
const key = directory
if (
@@ -122,13 +106,18 @@ export function createChildStoreManager(input: {
return false
}
return disposeChild(key)
}
function disposeAll() {
for (const directory of Object.keys(children)) {
disposeChild(directoryKey(directory))
vcsCache.delete(key)
metaCache.delete(key)
iconCache.delete(key)
lifecycle.delete(key)
const dispose = disposers.get(key)
if (dispose) {
dispose()
disposers.delete(key)
}
delete children[key]
input.onDispose(key)
return true
}
function runEviction(skip?: string) {
@@ -340,7 +329,6 @@ export function createChildStoreManager(input: {
unpin,
pinned,
disposeDirectory,
disposeAll,
runEviction,
vcsCache,
metaCache,

View File

@@ -9,88 +9,6 @@ type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: stri
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
type UpdateInfo = { updateAvailable: boolean; version?: string }
export type WslRuntimeCheck = {
available: boolean
version: string | null
error: string | null
}
export type WslInstalledDistro = {
name: string
version: number | null
isDefault: boolean
}
export type WslOnlineDistro = {
name: string
label: string
}
export type WslDistroProbe = {
name: string
canExecute: boolean
hasBash: boolean
hasCurl: boolean
error: string | null
}
export type WslOpencodeCheck = {
distro: string
resolvedPath: string | null
version: string | null
expectedVersion: string | null
matchesDesktop: boolean | null
error: string | null
}
export type WslServerConfig = {
id: string
distro: string
}
export type WslServerRuntime =
| { kind: "starting" }
| { kind: "ready"; url: string; username: string | null; password: string | null }
| { kind: "failed"; message: string }
| { kind: "stopped" }
export type WslServerItem = {
config: WslServerConfig
runtime: WslServerRuntime
}
export type WslJob =
| { kind: "runtime"; startedAt: number }
| { kind: "distros"; startedAt: number }
| { kind: "install-wsl"; startedAt: number }
| { kind: "install-distro"; distro: string; startedAt: number }
| { kind: "probe-distro"; distro: string; startedAt: number }
| { kind: "probe-opencode"; distro: string; startedAt: number }
| { kind: "install-opencode"; distro: string; startedAt: number }
export type WslServersState = {
runtime: WslRuntimeCheck | null
installed: WslInstalledDistro[]
online: WslOnlineDistro[]
distroProbes: Record<string, WslDistroProbe>
opencodeChecks: Record<string, WslOpencodeCheck>
pendingRestart: boolean
servers: WslServerItem[]
job: WslJob | null
}
export type WslServersEvent = { type: "state"; state: WslServersState }
export type WslServersPlatform = {
getState(): Promise<WslServersState>
subscribe(cb: (event: WslServersEvent) => void): () => void
probeRuntime(): Promise<void>
refreshDistros(): Promise<void>
installWsl(): Promise<void>
installDistro(name: string): Promise<void>
probeDistro(name: string): Promise<void>
probeOpencode(name: string): Promise<void>
installOpencode(name: string): Promise<void>
openTerminal(name: string): Promise<void>
addServer(distro: string): Promise<WslServerConfig>
removeServer(id: string): Promise<void>
startServer(id: string): Promise<void>
}
export type Platform = {
/** Platform discriminator */
platform: "web" | "desktop"
@@ -146,8 +64,11 @@ export type Platform = {
/** Set the default server URL to use on app startup (platform-specific) */
setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void
/** Manage WSL sidecar servers (Electron on Windows only) */
wslServers?: WslServersPlatform
/** Get the configured WSL integration (desktop only) */
getWslEnabled?(): Promise<boolean>
/** Set the configured WSL integration (desktop only) */
setWslEnabled?(config: boolean): Promise<void> | void
/** Get the preferred display backend (desktop only) */
getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null

View File

@@ -1,53 +0,0 @@
import { describe, expect, test } from "bun:test"
import { resolveServerList, ServerConnection } from "./server"
describe("resolveServerList", () => {
test("lets startup auth_token credentials override a persisted same-url server", () => {
const list = resolveServerList({
stored: [{ url: "https://server.example.test" }],
props: [
{
type: "http",
authToken: true,
http: {
url: "https://server.example.test",
username: "opencode",
password: "secret",
},
},
],
})
expect(list).toHaveLength(1)
expect(list[0]?.type).toBe("http")
expect(list[0]?.http).toEqual({
url: "https://server.example.test",
username: "opencode",
password: "secret",
})
expect(list[0]?.type === "http" ? list[0].authToken : false).toBe(true)
expect(ServerConnection.key(list[0]!) as string).toBe("https://server.example.test")
})
test("keeps persisted credentials when startup has no auth_token", () => {
const list = resolveServerList({
stored: [
{
url: "https://server.example.test",
username: "opencode",
password: "saved",
},
],
props: [{ type: "http", http: { url: "https://server.example.test" } }],
})
expect(list).toHaveLength(1)
expect(list[0]?.type).toBe("http")
expect(list[0]?.http).toEqual({
url: "https://server.example.test",
username: "opencode",
password: "saved",
})
expect(list[0]?.type === "http" ? list[0].authToken : true).toBeUndefined()
})
})

View File

@@ -33,33 +33,6 @@ function isLocalHost(url: string) {
if (host === "localhost" || host === "127.0.0.1") return "local"
}
export function resolveServerList(input: {
props?: Array<ServerConnection.Any>
stored: StoredServer[]
}): Array<ServerConnection.Any> {
const servers = [
...input.stored.map((value) =>
typeof value === "string"
? {
type: "http" as const,
http: { url: value },
}
: value,
),
...(input.props ?? []),
]
const deduped = new Map<ServerConnection.Key, ServerConnection.Any>()
for (const value of servers) {
const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
const key = ServerConnection.key(conn)
if (deduped.has(key) && conn.type === "http" && !conn.authToken) continue
deduped.set(key, conn)
}
return [...deduped.values()]
}
export namespace ServerConnection {
type Base = { displayName?: string }
@@ -73,7 +46,6 @@ export namespace ServerConnection {
export type Http = {
type: "http"
http: HttpBase
authToken?: boolean
} & Base
export type Sidecar = {
@@ -141,7 +113,26 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
const allServers = createMemo((): Array<ServerConnection.Any> => {
return resolveServerList({ stored: store.list, props: props.servers })
const servers = [
...(props.servers ?? []),
...store.list.map((value) =>
typeof value === "string"
? {
type: "http" as const,
http: { url: value },
}
: value,
),
]
const deduped = new Map(
servers.map((value) => {
const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
return [ServerConnection.key(conn), conn]
}),
)
return [...deduped.values()]
})
const [state, setState] = createStore({
@@ -180,16 +171,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
if (state.active !== input) setState("active", input)
}
createEffect(() => {
if (typeof window === "undefined") return
window.__OPENCODE__ ??= {}
window.__OPENCODE__.activeServer = state.active
})
function add(input: ServerConnection.Http) {
const url_ = normalizeServerUrl(input.http.url)
if (!url_) return
const conn: ServerConnection.Http = { ...input, authToken: undefined, http: { ...input.http, url: url_ } }
const conn = { ...input, http: { ...input.http, url: url_ } }
return batch(() => {
const existing = store.list.findIndex((x) => url(x) === url_)
if (existing !== -1) {
@@ -236,7 +221,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
)
const isLocal = createMemo(() => {
const c = current()
return c?.type === "sidecar" || (c?.type === "http" && isLocalHost(c.http.url))
return (c?.type === "sidecar" && c.variant === "base") || (c?.type === "http" && isLocalHost(c.http.url))
})
return {

View File

@@ -1,9 +1,6 @@
import { beforeAll, describe, expect, mock, test } from "bun:test"
type ServerKey = Parameters<typeof import("./terminal").getTerminalServerScope>[1]
let getWorkspaceTerminalCacheKey: (dir: string, scope?: string) => string
let getTerminalServerScope: typeof import("./terminal").getTerminalServerScope
let getWorkspaceTerminalCacheKey: (dir: string) => string
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
let migrateTerminalState: (value: unknown) => unknown
@@ -20,7 +17,6 @@ beforeAll(async () => {
}))
const mod = await import("./terminal")
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
getTerminalServerScope = mod.getTerminalServerScope
getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
migrateTerminalState = mod.migrateTerminalState
})
@@ -29,45 +25,6 @@ describe("getWorkspaceTerminalCacheKey", () => {
test("uses workspace-only directory cache key", () => {
expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__")
})
test("can include a server scope", () => {
expect(getWorkspaceTerminalCacheKey("/repo", "wsl:Debian")).toBe("wsl:Debian:/repo:__workspace__")
})
})
describe("getTerminalServerScope", () => {
test("preserves local server keys", () => {
expect(
getTerminalServerScope(
{ type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } },
"sidecar" as ServerKey,
),
).toBeUndefined()
expect(
getTerminalServerScope(
{ type: "http", http: { url: "http://localhost:4096" } },
"http://localhost:4096" as ServerKey,
),
).toBeUndefined()
expect(
getTerminalServerScope({ type: "http", http: { url: "http://[::1]:4096" } }, "http://[::1]:4096" as ServerKey),
).toBeUndefined()
})
test("scopes non-local server keys", () => {
expect(
getTerminalServerScope(
{ type: "sidecar", variant: "wsl", distro: "Debian", http: { url: "http://127.0.0.1:4096" } },
"wsl:Debian" as ServerKey,
),
).toBe("wsl:Debian" as ServerKey)
expect(
getTerminalServerScope(
{ type: "http", http: { url: "https://example.com" } },
"https://example.com" as ServerKey,
),
).toBe("https://example.com" as ServerKey)
})
})
describe("getLegacyTerminalStorageKeys", () => {

View File

@@ -4,7 +4,6 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli
import { useParams } from "@solidjs/router"
import { useSDK } from "./sdk"
import type { Platform } from "./platform"
import { ServerConnection, useServer } from "./server"
import { defaultTitle, titleNumber } from "./terminal-title"
import { Persist, persisted, removePersisted } from "@/utils/persist"
@@ -83,31 +82,10 @@ export function migrateTerminalState(value: unknown) {
}
}
export function getWorkspaceTerminalCacheKey(dir: string, scope?: string) {
if (scope) return `${scope}:${dir}:${WORKSPACE_KEY}`
export function getWorkspaceTerminalCacheKey(dir: string) {
return `${dir}:${WORKSPACE_KEY}`
}
export function getTerminalServerScope(conn: ServerConnection.Any | undefined, key: ServerConnection.Key) {
if (!conn) return
if (conn.type === "sidecar" && conn.variant === "base") return
if (conn.type === "http") {
try {
const url = new URL(conn.http.url)
if (
url.hostname === "localhost" ||
url.hostname === "127.0.0.1" ||
url.hostname === "::1" ||
url.hostname === "[::1]"
)
return
} catch {
return key
}
}
return key
}
export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) {
if (!legacySessionID) return [`${dir}/terminal.v1`]
return [`${dir}/terminal/${legacySessionID}.v1`, `${dir}/terminal.v1`]
@@ -132,16 +110,15 @@ const trimTerminal = (pty: LocalPTY) => {
}
}
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform, scope?: string) {
const key = getWorkspaceTerminalCacheKey(dir, scope)
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) {
const key = getWorkspaceTerminalCacheKey(dir)
for (const cache of caches) {
const entry = cache.get(key)
entry?.value.clear()
}
void removePersisted(Persist.workspace(dir, scope ? `terminal:${scope}` : "terminal"), platform)
void removePersisted(Persist.workspace(dir, "terminal"), platform)
if (scope) return
const legacy = new Set(getLegacyTerminalStorageKeys(dir))
for (const id of sessionIDs ?? []) {
for (const key of getLegacyTerminalStorageKeys(dir, id)) {
@@ -153,17 +130,12 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
}
}
function createWorkspaceTerminalSession(
sdk: ReturnType<typeof useSDK>,
dir: string,
legacySessionID?: string,
scope?: string,
) {
const legacy = scope ? [] : getLegacyTerminalStorageKeys(dir, legacySessionID)
function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
const [store, setStore, _, ready] = persisted(
{
...Persist.workspace(dir, scope ? `terminal:${scope}` : "terminal", legacy),
...Persist.workspace(dir, "terminal", legacy),
migrate: migrateTerminalState,
},
createStore<{
@@ -385,12 +357,8 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
gate: false,
init: () => {
const sdk = useSDK()
const server = useServer()
const params = useParams()
const cache = new Map<string, TerminalCacheEntry>()
const scope = createMemo(() => {
return getTerminalServerScope(server.current, server.key)
})
caches.add(cache)
onCleanup(() => caches.delete(cache))
@@ -414,9 +382,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
}
const loadWorkspace = (dir: string, legacySessionID: string | undefined, serverScope: string | undefined) => {
const loadWorkspace = (dir: string, legacySessionID?: string) => {
// Terminals are workspace-scoped so tabs persist while switching sessions in the same directory.
const key = getWorkspaceTerminalCacheKey(dir, serverScope)
const key = getWorkspaceTerminalCacheKey(dir)
const existing = cache.get(key)
if (existing) {
cache.delete(key)
@@ -425,7 +393,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
const entry = createRoot((dispose) => ({
value: createWorkspaceTerminalSession(sdk, dir, legacySessionID, serverScope),
value: createWorkspaceTerminalSession(sdk, dir, legacySessionID),
dispose,
}))
@@ -434,16 +402,16 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
return entry.value
}
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope()))
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
createEffect(
on(
() => ({ dir: params.dir, id: params.id, scope: scope() }),
() => ({ dir: params.dir, id: params.id }),
(next, prev) => {
if (!prev?.dir) return
if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return
if (next.dir === prev.dir && next.id && next.scope === prev.scope) return
loadWorkspace(prev.dir, prev.id, prev.scope).trimAll()
if (next.dir === prev.dir && next.id === prev.id) return
if (next.dir === prev.dir && next.id) return
loadWorkspace(prev.dir, prev.id).trimAll()
},
{ defer: true },
),

View File

@@ -1,35 +0,0 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { queryOptions, skipToken, useQuery, useQueryClient } from "@tanstack/solid-query"
import { createEffect, onCleanup } from "solid-js"
import type { WslServersPlatform, WslServersState } from "./platform"
import { usePlatform } from "./platform"
const wslServersQueryKey = ["platform", "wslServers"] as const
export const { use: useWslServers, provider: WslServersProvider } = createSimpleContext({
name: "WslServers",
init: () => {
const platform = usePlatform()
const queryClient = useQueryClient()
const query = useQuery(() => {
const api = platform.wslServers
return queryOptions<WslServersState>({
queryKey: wslServersQueryKey,
queryFn: api ? () => api.getState() : skipToken,
staleTime: Number.POSITIVE_INFINITY,
gcTime: Number.POSITIVE_INFINITY,
})
})
createEffect(() => {
const api = platform.wslServers
if (!api) return
const off = api.subscribe((event) => {
queryClient.setQueryData(wslServersQueryKey, event.state)
})
onCleanup(off)
})
return query
},
})

View File

@@ -7,7 +7,6 @@ import { type Platform, PlatformProvider } from "@/context/platform"
import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh"
import { handleNotificationClick } from "@/utils/notification-click"
import { authFromToken } from "@/utils/server"
import pkg from "../package.json"
import { ServerConnection } from "./context/server"
@@ -112,13 +111,6 @@ const getDefaultUrl = () => {
return getCurrentUrl()
}
const clearAuthToken = () => {
const params = new URLSearchParams(location.search)
if (!params.has("auth_token")) return
params.delete("auth_token")
history.replaceState(null, "", location.pathname + (params.size ? `?${params}` : "") + location.hash)
}
const platform: Platform = {
platform: "web",
version: pkg.version,
@@ -154,16 +146,7 @@ if (import.meta.env.VITE_SENTRY_DSN) {
}
if (root instanceof HTMLElement) {
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
clearAuthToken()
const server: ServerConnection.Http = {
type: "http",
authToken: !!auth,
http: {
url: getCurrentUrl(),
...auth,
},
}
const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } }
render(
() => (
<PlatformProvider value={platform}>

View File

@@ -2,19 +2,6 @@ export { AppBaseProviders, AppInterface } from "./app"
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
export { useCommand } from "./context/command"
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
export { useWslServers } from "./context/wsl-servers"
export {
type DisplayBackend,
type Platform,
PlatformProvider,
type WslInstalledDistro,
type WslOnlineDistro,
type WslOpencodeCheck,
type WslServerConfig,
type WslServerItem,
type WslServersEvent,
type WslServersPlatform,
type WslServersState,
} from "./context/platform"
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
export { ServerConnection } from "./context/server"
export { handleNotificationClick } from "./utils/notification-click"

View File

@@ -36,7 +36,6 @@ export default function Home() {
if (healthy === false) return "bg-icon-critical-base"
return "bg-border-weak-base"
})
const useWebDirectoryPicker = createMemo(() => server.current?.type === "sidecar" && server.current.variant === "wsl")
function openProject(directory: string) {
layout.projects.open(directory)
@@ -55,7 +54,7 @@ export default function Home() {
}
}
if (platform.openDirectoryPickerDialog && server.isLocal() && !useWebDirectoryPicker()) {
if (platform.openDirectoryPickerDialog && server.isLocal()) {
const result = await platform.openDirectoryPickerDialog?.({
title: language.t("command.project.open"),
multiple: true,
@@ -76,7 +75,7 @@ export default function Home() {
size="large"
variant="ghost"
class="mt-4 mx-auto text-14-regular text-text-weak"
onClick={() => dialog.show(() => <DialogSelectServer onNavigateHome={() => navigate("/")} />)}
onClick={() => dialog.show(() => <DialogSelectServer />)}
>
<div
classList={{

View File

@@ -35,7 +35,7 @@ import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useProviders } from "@/hooks/use-providers"
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
import { useGlobalSDK } from "@/context/global-sdk"
import { clearWorkspaceTerminals, getTerminalServerScope } from "@/context/terminal"
import { clearWorkspaceTerminals } from "@/context/terminal"
import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
import {
clearSessionPrefetchInflight,
@@ -149,7 +149,6 @@ export default function Layout(props: ParentProps) {
}
const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
const currentDir = createMemo(() => route().dir)
const useWebDirectoryPicker = createMemo(() => server.current?.type === "sidecar" && server.current.variant === "wsl")
const [state, setState] = createStore({
autoselect: !initialDirectory,
@@ -1208,7 +1207,7 @@ export default function Layout(props: ParentProps) {
const run = ++dialogRun
void import("@/components/dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectServer onNavigateHome={() => navigate("/")} />)
dialog.show(() => <x.DialogSelectServer />)
})
}
@@ -1460,7 +1459,7 @@ export default function Layout(props: ParentProps) {
}
}
if (platform.openDirectoryPickerDialog && server.isLocal() && !useWebDirectoryPicker()) {
if (platform.openDirectoryPickerDialog && server.isLocal()) {
const result = await platform.openDirectoryPickerDialog?.({
title: language.t("command.project.open"),
multiple: true,
@@ -1558,7 +1557,6 @@ export default function Layout(props: ParentProps) {
directory,
sessions.map((s) => s.id),
platform,
getTerminalServerScope(server.current, server.key),
)
await globalSDK.client.instance.dispose({ directory }).catch(() => undefined)

View File

@@ -37,7 +37,6 @@ export function TerminalPanel() {
const [store, setStore] = createStore({
autoCreated: false,
activeDraggable: undefined as string | undefined,
recovered: {} as Record<string, boolean>,
view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight),
})
@@ -146,21 +145,6 @@ export function TerminalPanel() {
const all = terminal.all
const ids = createMemo(() => all().map((pty) => pty.id))
const recoverTerminal = (key: string, id: string, clone: (id: string) => Promise<void>) => {
if (store.recovered[key]) return
setStore("recovered", key, true)
void clone(id)
}
const terminalRecoveryKey = (pty: { id: string; title: string; titleNumber: number }) => {
return String(pty.titleNumber || pty.title || pty.id)
}
const markTerminalConnected = (key: string, id: string, trim: (id: string) => void) => {
setStore("recovered", key, false)
trim(id)
}
const handleTerminalDragStart = (event: unknown) => {
const id = getDraggableId(event)
if (!id) return
@@ -296,9 +280,9 @@ export function TerminalPanel() {
<Terminal
pty={pty()}
autoFocus={opened()}
onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)}
onConnect={() => ops.trim(id)}
onCleanup={ops.update}
onConnectError={() => recoverTerminal(terminalRecoveryKey(pty()), id, ops.clone)}
onConnectError={() => ops.clone(id)}
/>
</div>
)}

View File

@@ -1,23 +0,0 @@
import { describe, expect, test } from "bun:test"
import { authFromToken, authTokenFromCredentials } from "./server"
describe("authFromToken", () => {
test("decodes basic auth credentials from auth_token", () => {
expect(authFromToken(btoa("kit:secret"))).toEqual({ username: "kit", password: "secret" })
})
test("defaults blank username to opencode", () => {
expect(authFromToken(btoa(":secret"))).toEqual({ username: "opencode", password: "secret" })
})
test("ignores malformed tokens", () => {
expect(authFromToken("not base64")).toBeUndefined()
expect(authFromToken(btoa("missing-separator"))).toBeUndefined()
})
})
describe("authTokenFromCredentials", () => {
test("encodes credentials with the default username", () => {
expect(authTokenFromCredentials({ password: "secret" })).toBe(btoa("opencode:secret"))
})
})

View File

@@ -1,21 +1,5 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { ServerConnection } from "@/context/server"
import { decode64 } from "@/utils/base64"
export function authTokenFromCredentials(input: { username?: string; password: string }) {
return btoa(`${input.username ?? "opencode"}:${input.password}`)
}
export function authFromToken(token: string | null) {
const decoded = decode64(token ?? undefined)
if (!decoded) return
const separator = decoded.indexOf(":")
if (separator === -1) return
return {
username: decoded.slice(0, separator) || "opencode",
password: decoded.slice(separator + 1),
}
}
export function createSdkForServer({
server,
@@ -26,7 +10,7 @@ export function createSdkForServer({
const auth = (() => {
if (!server.password) return
return {
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
Authorization: `Basic ${btoa(`${server.username ?? "opencode"}:${server.password}`)}`,
}
})()

View File

@@ -1,6 +1,6 @@
import { useDragDropContext } from "@thisbeyond/solid-dnd"
import type { Transformer } from "@thisbeyond/solid-dnd"
import type { JSXElement } from "solid-js"
import { createRoot, onCleanup, type JSXElement } from "solid-js"
type DragEvent = { draggable?: { id?: unknown } }
@@ -27,16 +27,20 @@ const createAxisConstraint = (axis: "x" | "y", transformerId: string) => (): JSX
if (!context) return null
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
const transformer = createTransformer(transformerId, axis)
onDragStart((event) => {
const id = getDraggableId(event)
if (!id) return
addTransformer("draggables", id, transformer)
})
onDragEnd((event) => {
const id = getDraggableId(event)
if (!id) return
removeTransformer("draggables", id, transformer.id)
const dispose = createRoot((dispose) => {
onDragStart((event) => {
const id = getDraggableId(event)
if (!id) return
addTransformer("draggables", id, transformer)
})
onDragEnd((event) => {
const id = getDraggableId(event)
if (!id) return
removeTransformer("draggables", id, transformer.id)
})
return dispose
})
onCleanup(dispose)
return null
}

View File

@@ -1,52 +0,0 @@
import { describe, expect, test } from "bun:test"
import { terminalWebSocketURL } from "./terminal-websocket-url"
describe("terminalWebSocketURL", () => {
test("uses query auth without embedding credentials in websocket URL", () => {
const url = terminalWebSocketURL({
url: "http://127.0.0.1:49365",
id: "pty_test",
directory: "/tmp/project",
cursor: 0,
sameOrigin: false,
username: "opencode",
password: "secret",
})
expect(url.protocol).toBe("ws:")
expect(url.username).toBe("")
expect(url.password).toBe("")
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
})
test("omits query auth for same-origin saved credentials", () => {
const url = terminalWebSocketURL({
url: "https://app.example.test",
id: "pty_test",
directory: "/tmp/project",
cursor: 10,
sameOrigin: true,
username: "opencode",
password: "secret",
})
expect(url.protocol).toBe("wss:")
expect(url.searchParams.has("auth_token")).toBe(false)
})
test("uses query auth for same-origin credentials from auth_token", () => {
const url = terminalWebSocketURL({
url: "https://app.example.test",
id: "pty_test",
directory: "/tmp/project",
cursor: 10,
sameOrigin: true,
username: "opencode",
password: "secret",
authToken: true,
})
expect(url.protocol).toBe("wss:")
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
})
})

View File

@@ -1,28 +0,0 @@
import { authTokenFromCredentials } from "@/utils/server"
export function terminalWebSocketURL(input: {
url: string
id: string
directory: string
cursor: number
ticket?: string
sameOrigin?: boolean
username?: string
password?: string
authToken?: boolean
}) {
const next = new URL(`${input.url}/pty/${input.id}/connect`)
next.searchParams.set("directory", input.directory)
next.searchParams.set("cursor", String(input.cursor))
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
if (input.ticket) {
next.searchParams.set("ticket", input.ticket)
return next
}
if (input.password && (!input.sameOrigin || input.authToken))
next.searchParams.set(
"auth_token",
authTokenFromCredentials({ username: input.username, password: input.password }),
)
return next
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.14.33",
"version": "1.14.32",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -158,13 +158,11 @@ export async function handler(
Object.entries(obj).flatMap(([k, v]) => {
if (Array.isArray(v)) return [[k, v]]
if (typeof v === "object") return [[k, replacer(v)]]
if (typeof v === "string") {
if (v === "$ip") return [[k, ip]]
if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : []
if (v.startsWith("$header.")) {
const headerValue = input.request.headers.get(v.slice(8))
return headerValue ? [[k, headerValue]] : []
}
if (v === "$ip") return [[k, ip]]
if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : []
if (v.startsWith("$header.")) {
const headerValue = input.request.headers.get(v.slice(8))
return headerValue ? [[k, headerValue]] : []
}
return [[k, v]]
}),
@@ -919,13 +917,6 @@ export async function handler(
"tokens.cache_read": cacheReadTokens,
"tokens.cache_write_5m": cacheWrite5mTokens,
"tokens.cache_write_1h": cacheWrite1hTokens,
"cost.input.microcents": centsToMicroCents(inputCost),
"cost.output.microcents": centsToMicroCents(outputCost),
"cost.reasoning.microcents": reasoningCost ? centsToMicroCents(reasoningCost) : undefined,
"cost.cache_read.microcents": cacheReadCost ? centsToMicroCents(cacheReadCost) : undefined,
"cost.cache_write.microcents": cacheWrite5mCost ? centsToMicroCents(cacheWrite5mCost) : undefined,
"cost.total.microcents": centsToMicroCents(totalCostInCent),
// deprecated - remove after May 20, 2026
"cost.input": Math.round(inputCost),
"cost.output": Math.round(outputCost),
"cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined,

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.14.33",
"version": "1.14.32",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.14.33",
"version": "1.14.32",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.14.33",
"version": "1.14.32",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.33",
"version": "1.14.32",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",

View File

@@ -24,7 +24,6 @@ export namespace AppFileSystem {
readonly isDir: (path: string) => Effect.Effect<boolean>
readonly isFile: (path: string) => Effect.Effect<boolean>
readonly existsSafe: (path: string) => Effect.Effect<boolean>
readonly readFileStringSafe: (path: string) => Effect.Effect<string | undefined, Error>
readonly readJson: (path: string) => Effect.Effect<unknown, Error>
readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
readonly ensureDir: (path: string) => Effect.Effect<void, Error>
@@ -48,12 +47,6 @@ export namespace AppFileSystem {
return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))
})
const readFileStringSafe = Effect.fn("FileSystem.readFileStringSafe")(function* (path: string) {
return yield* fs
.readFileString(path)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
})
const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
return info?.type === "Directory"
@@ -170,7 +163,6 @@ export namespace AppFileSystem {
return Service.of({
...fs,
existsSafe,
readFileStringSafe,
isDir,
isFile,
readDirectoryEntries,

View File

@@ -1,5 +1,4 @@
import { Config } from "effect"
import { InstallationChannel } from "../installation/version"
function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
@@ -11,10 +10,6 @@ function falsy(key: string) {
return value === "false" || value === "0"
}
// Channels that default to the new effect-httpapi server backend. The legacy
// hono backend remains the default for stable (`prod`/`latest`) installs.
const HTTPAPI_DEFAULT_ON_CHANNELS = new Set(["dev", "beta", "local"])
function number(key: string) {
const value = process.env[key]
if (!value) return undefined
@@ -86,16 +81,8 @@ export const Flag = {
OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"),
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
// Defaults to true on dev/beta/local channels so internal users exercise the
// new effect-httpapi server backend. Stable (`prod`/`latest`) installs stay
// on the legacy hono backend until the rollout is complete. An explicit env
// var ("true"/"1" or "false"/"0") always wins, providing an opt-in for
// stable users and an escape hatch for dev/beta users.
OPENCODE_EXPERIMENTAL_HTTPAPI:
truthy("OPENCODE_EXPERIMENTAL_HTTPAPI") ||
(!falsy("OPENCODE_EXPERIMENTAL_HTTPAPI") && HTTPAPI_DEFAULT_ON_CHANNELS.has(InstallationChannel)),
OPENCODE_EXPERIMENTAL_HTTPAPI: truthy("OPENCODE_EXPERIMENTAL_HTTPAPI"),
OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"),
OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
// Evaluated at access time (not module load) because tests, the CLI, and
// external tooling set these env vars at runtime.

View File

@@ -1,5 +1,3 @@
export * as Log from "./log"
import path from "path"
import fs from "fs/promises"
import { createWriteStream } from "fs"

View File

@@ -65,34 +65,6 @@ describe("AppFileSystem", () => {
)
})
describe("readFileStringSafe", () => {
it(
"returns file contents when file exists",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const filesys = yield* FileSystem.FileSystem
const tmp = yield* filesys.makeTempDirectoryScoped()
const file = path.join(tmp, "exists.txt")
yield* filesys.writeFileString(file, "hello")
const result = yield* fs.readFileStringSafe(file)
expect(result).toBe("hello")
}),
)
it(
"returns undefined for missing file (NotFound)",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const filesys = yield* FileSystem.FileSystem
const tmp = yield* filesys.makeTempDirectoryScoped()
const result = yield* fs.readFileStringSafe(path.join(tmp, "does-not-exist.txt"))
expect(result).toBeUndefined()
}),
)
})
describe("readJson / writeJson", () => {
it(
"round-trips JSON data",

View File

@@ -27,7 +27,7 @@ const channel = (() => {
})()
const getBase = (): Configuration => ({
artifactName: "opencode-desktop-${os}-${arch}.${ext}",
artifactName: "opencode-electron-${os}-${arch}.${ext}",
directories: {
output: "dist",
buildResources: "resources",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.14.33",
"version": "1.14.32",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",

View File

@@ -1,7 +1,6 @@
import { execFileSync } from "node:child_process"
import { existsSync, readFileSync, readdirSync } from "node:fs"
import { dirname, extname, join } from "node:path"
import { resolveWslHome, runWslInDistro } from "./wsl"
export function checkAppExists(appName: string): boolean {
if (process.platform === "win32") return true
@@ -14,44 +13,20 @@ export function resolveAppPath(appName: string): string | null {
return resolveWindowsAppPath(appName)
}
// Parses `\\wsl$\<distro>\...` and `\\wsl.localhost\<distro>\...` UNC paths that
// point *into* a WSL distro's rootfs. `wslpath -u` cannot handle these reliably:
// backslashes get shell-collapsed when passed through `wsl.exe`, turning
// `\\wsl.localhost\Debian\home\luke` into `/mnt/c/wsl.localhostDebianhomeluke`,
// which is a valid-looking path that wedges opencode on DrvFs stat calls.
function parseWslUncPath(value: string): { distro: string; subpath: string } | null {
// Normalise separators; both `\\` and `//` prefixes mean UNC.
const normalised = value.replace(/\\/g, "/").replace(/^\/+/, "//")
const match = /^\/\/(wsl\$|wsl\.localhost)\/([^/]+)(?:\/(.*))?$/i.exec(normalised)
if (!match) return null
const distro = match[2]
const subpath = match[3] ?? ""
return { distro, subpath }
}
export async function wslPath(path: string, mode: "windows" | "linux" | null, distro?: string | null): Promise<string> {
export function wslPath(path: string, mode: "windows" | "linux" | null): string {
if (process.platform !== "win32") return path
// `\\wsl$\<distro>\...` / `\\wsl.localhost\<distro>\...` -> `/<subpath>` in
// the target distro. Do the conversion in-process rather than shelling out
// to `wslpath -u`, which mangles backslashes via wsl.exe's command-line
// joiner. If the requested distro differs from the UNC distro, we still
// translate literally — callers are responsible for only picking paths
// inside the active distro.
if (mode === "linux") {
const unc = parseWslUncPath(path)
if (unc) return `/${unc.subpath}`
}
const flag = mode === "windows" ? "-w" : "-u"
try {
const resolved = path.startsWith("~") ? `${await resolveWslHome(distro)}${path.slice(1)}` : path
const input = mode === "linux" ? resolved.replace(/\\/g, "/") : resolved
const output = await runWslInDistro(["wslpath", flag, input], distro)
if (output.code !== 0) {
throw new Error(output.stderr || output.stdout || `wslpath exited with code ${output.code}`)
if (path.startsWith("~")) {
const suffix = path.slice(1)
const cmd = `wslpath ${flag} "$HOME${suffix.replace(/"/g, '\\"')}"`
const output = execFileSync("wsl", ["-e", "sh", "-lc", cmd])
return output.toString().trim()
}
return output.stdout.trim()
const output = execFileSync("wsl", ["-e", "wslpath", flag, path])
return output.toString().trim()
} catch (error) {
throw new Error(`Failed to run wslpath: ${String(error)}`, { cause: error })
}

View File

@@ -6,5 +6,5 @@ export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod
export const SETTINGS_STORE = "opencode.settings"
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
export const WSL_SERVERS_KEY = "wslServers"
export const WSL_ENABLED_KEY = "wslEnabled"
export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"

View File

@@ -1,13 +1,12 @@
import { randomUUID } from "node:crypto"
import { EventEmitter } from "node:events"
import { existsSync } from "node:fs"
import { createServer } from "node:net"
import { homedir } from "node:os"
import { join } from "node:path"
import type { Event } from "electron"
import { app, BrowserWindow, dialog } from "electron"
import pkg from "electron-updater"
import { drizzle } from "drizzle-orm/node-sqlite/driver"
import type { Server } from "virtual:opencode-server"
import contextMenu from "electron-context-menu"
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
@@ -35,15 +34,14 @@ app.setAppUserModelId(appId)
app.setPath("userData", join(app.getPath("appData"), appId))
const { autoUpdater } = pkg
import type { InitStep, ServerReadyData, SqliteMigrationProgress } from "../preload/types"
import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
import { CHANNEL, UPDATER_ENABLED } from "./constants"
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
import { initLogging } from "./logging"
import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
import { allocatePort, getDefaultServerUrl, setDefaultServerUrl, spawnLocalServer, spawnWslSidecar } from "./server"
import { createWslServersController } from "./wsl-servers"
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
import {
createLoadingWindow,
createMainWindow,
@@ -51,6 +49,8 @@ import {
setBackgroundColor,
setDockIcon,
} from "./windows"
import { drizzle } from "drizzle-orm/node-sqlite/driver"
import type { Server } from "virtual:opencode-server"
const initEmitter = new EventEmitter()
let initStep: InitStep = { phase: "server_waiting" }
@@ -63,19 +63,6 @@ const pendingDeepLinks: string[] = []
const serverReady = defer<ServerReadyData>()
const logger = initLogging()
const wslServers = createWslServersController(
app.getVersion(),
async (distro) => {
logger.log("spawning wsl sidecar", { distro })
return spawnWslSidecar(distro, {
onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }),
})
},
{
log: (message, meta) => logger.log(message, meta),
error: (message, meta) => logger.error(message, meta),
},
)
logger.log("app starting", {
version: app.getVersion(),
@@ -87,7 +74,6 @@ setupApp()
function setupApp() {
ensureLoopbackNoProxy()
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222")
if (!app.requestSingleInstanceLock()) {
app.quit()
@@ -111,18 +97,15 @@ function setupApp() {
app.on("before-quit", () => {
killSidecar()
wslServers.stopAll()
})
app.on("will-quit", () => {
killSidecar()
wslServers.stopAll()
})
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
killSidecar()
wslServers.stopAll()
app.exit(0)
})
}
@@ -156,9 +139,10 @@ function setInitStep(step: InitStep) {
async function initialize() {
const needsMigration = !sqliteFileExists()
const sqliteDone = needsMigration ? defer<void>() : undefined
let overlay: BrowserWindow | null = null
const port = await allocatePort()
const port = await getSidecarPort()
const hostname = "127.0.0.1"
const url = `http://${hostname}:${port}`
const password = randomUUID()
@@ -170,17 +154,24 @@ async function initialize() {
setInitStep({ phase: "sqlite_waiting" })
if (overlay) sendSqliteMigrationProgress(overlay, progress)
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
if (progress.type === "Done") sqliteDone?.resolve()
})
if (needsMigration) {
const { Database, JsonMigration } = await import("virtual:opencode-server")
await JsonMigration.run(drizzle({ client: Database.Client().$client }), {
progress: (event: { current: number; total: number }) => {
const percent = Math.round((event.current / event.total) * 100)
const percent = Math.round(event.current / event.total) * 100
initEmitter.emit("sqlite", { type: "InProgress", value: percent })
},
})
initEmitter.emit("sqlite", { type: "Done" })
sqliteDone?.resolve()
}
if (needsMigration) {
await sqliteDone?.promise
}
logger.log("spawning sidecar", { url })
@@ -192,9 +183,6 @@ async function initialize() {
password,
})
// Initialize WSL sidecars in parallel; failures do not block app startup.
void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error))
await Promise.race([
health.wait,
delay(30_000).then(() => {
@@ -236,13 +224,16 @@ function wireMenu() {
void checkForUpdates(true)
},
reload: () => mainWindow?.reload(),
relaunch: () => relaunchApp(),
relaunch: () => {
killSidecar()
app.relaunch()
app.exit(0)
},
})
}
registerIpcHandlers({
killSidecar: () => killSidecar(),
relaunch: () => relaunchApp(),
awaitInitialization: async (sendStep) => {
sendStep(initStep)
const listener = (step: InitStep) => sendStep(step)
@@ -256,28 +247,17 @@ registerIpcHandlers({
initEmitter.off("step", listener)
}
},
getWslServersState: () => wslServers.getState(),
onWslServersEvent: (listener) => wslServers.subscribe(listener),
wslServersProbeRuntime: () => wslServers.probeRuntime(),
wslServersRefreshDistros: () => wslServers.refreshDistros(),
wslServersInstallWsl: () => wslServers.installWsl(),
wslServersInstallDistro: (name) => wslServers.installDistro(name),
wslServersProbeDistro: (name) => wslServers.probeDistro(name),
wslServersProbeOpencode: (name) => wslServers.probeOpencode(name),
wslServersInstallOpencode: (name) => wslServers.installOpencode(name),
wslServersOpenTerminal: (name) => wslServers.openTerminal(name),
wslServersAddServer: (distro) => wslServers.addServer(distro),
wslServersRemoveServer: (id) => wslServers.removeServer(id),
wslServersStartServer: (id) => wslServers.startServer(id),
getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }),
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
getDefaultServerUrl: () => getDefaultServerUrl(),
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
getWslConfig: () => Promise.resolve(getWslConfig()),
setWslConfig: (config: WslConfig) => setWslConfig(config),
getDisplayBackend: async () => null,
setDisplayBackend: async () => undefined,
parseMarkdown: async (markdown) => parseMarkdown(markdown),
checkAppExists: async (appName) => checkAppExists(appName),
wslPath: async (path, mode, distro) => wslPath(path, mode, distro),
wslPath: async (path, mode) => wslPath(path, mode),
resolveAppPath: async (appName) => resolveAppPath(appName),
loadingWindowComplete: () => loadingComplete.resolve(),
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail),
@@ -292,15 +272,6 @@ function killSidecar() {
server = null
}
function relaunchApp() {
// app.exit() skips before-quit / will-quit, so relaunch callers must
// explicitly stop sidecars here rather than relying on process hooks.
killSidecar()
wslServers.stopAll()
app.relaunch()
app.exit(0)
}
function ensureLoopbackNoProxy() {
const loopback = ["127.0.0.1", "localhost", "::1"]
const upsert = (key: string) => {
@@ -321,6 +292,29 @@ function ensureLoopbackNoProxy() {
upsert("no_proxy")
}
async function getSidecarPort() {
const fromEnv = process.env.OPENCODE_PORT
if (fromEnv) {
const parsed = Number.parseInt(fromEnv, 10)
if (!Number.isNaN(parsed)) return parsed
}
return await new Promise<number>((resolve, reject) => {
const server = createServer()
server.on("error", reject)
server.listen(0, "127.0.0.1", () => {
const address = server.address()
if (typeof address !== "object" || !address) {
server.close()
reject(new Error("Failed to get port"))
return
}
const port = address.port
server.close(() => resolve(port))
})
})
}
function sqliteFileExists() {
const xdg = process.env.XDG_DATA_HOME
const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share")
@@ -397,7 +391,6 @@ async function installUpdate() {
version: downloadedUpdateVersion,
})
killSidecar()
wslServers.stopAll()
autoUpdater.quitAndInstall()
}

View File

@@ -8,9 +8,7 @@ import type {
SqliteMigrationProgress,
TitlebarTheme,
WindowConfig,
WslServerConfig,
WslServersEvent,
WslServersState,
WslConfig,
} from "../preload/types"
import { getStore } from "./store"
import { setTitlebar } from "./windows"
@@ -22,30 +20,18 @@ const pickerFilters = (ext?: string[]) => {
type Deps = {
killSidecar: () => void
relaunch: () => void
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
getWslServersState: () => Promise<WslServersState> | WslServersState
onWslServersEvent: (listener: (event: WslServersEvent) => void) => () => void
wslServersProbeRuntime: () => Promise<void> | void
wslServersRefreshDistros: () => Promise<void> | void
wslServersInstallWsl: () => Promise<void> | void
wslServersInstallDistro: (name: string) => Promise<void> | void
wslServersProbeDistro: (name: string) => Promise<void> | void
wslServersProbeOpencode: (name: string) => Promise<void> | void
wslServersInstallOpencode: (name: string) => Promise<void> | void
wslServersOpenTerminal: (name: string) => Promise<void> | void
wslServersAddServer: (distro: string) => Promise<WslServerConfig> | WslServerConfig
wslServersRemoveServer: (id: string) => Promise<void> | void
wslServersStartServer: (id: string) => Promise<void> | void
getWindowConfig: () => Promise<WindowConfig> | WindowConfig
consumeInitialDeepLinks: () => Promise<string[]> | string[]
getDefaultServerUrl: () => Promise<string | null> | string | null
setDefaultServerUrl: (url: string | null) => Promise<void> | void
getWslConfig: () => Promise<WslConfig>
setWslConfig: (config: WslConfig) => Promise<void> | void
getDisplayBackend: () => Promise<string | null>
setDisplayBackend: (backend: string | null) => Promise<void> | void
parseMarkdown: (markdown: string) => Promise<string> | string
checkAppExists: (appName: string) => Promise<boolean> | boolean
wslPath: (path: string, mode: "windows" | "linux" | null, distro?: string | null) => Promise<string>
wslPath: (path: string, mode: "windows" | "linux" | null) => Promise<string>
resolveAppPath: (appName: string) => Promise<string | null>
loadingWindowComplete: () => void
runUpdater: (alertOnFail: boolean) => Promise<void> | void
@@ -55,89 +41,27 @@ type Deps = {
}
export function registerIpcHandlers(deps: Deps) {
const requireString = (name: string, value: unknown) => {
if (typeof value === "string" && value.length > 0) return value
throw new Error(`Invalid ${name}`)
}
const wslSubscriptions = new Map<number, () => void>()
const unsubscribeWsl = (id: number) => {
const off = wslSubscriptions.get(id)
if (!off) return
off()
wslSubscriptions.delete(id)
}
app.once("will-quit", () => {
for (const off of wslSubscriptions.values()) off()
wslSubscriptions.clear()
})
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => {
const send = (step: InitStep) => event.sender.send("init-step", step)
return deps.awaitInitialization(send)
})
ipcMain.handle("wsl-servers-subscribe", (event) => {
const id = event.sender.id
if (wslSubscriptions.has(id)) return
wslSubscriptions.set(
id,
deps.onWslServersEvent((payload) => {
if (event.sender.isDestroyed()) {
unsubscribeWsl(id)
return
}
event.sender.send("wsl-servers-event", payload)
}),
)
event.sender.once("destroyed", () => unsubscribeWsl(id))
})
ipcMain.handle("wsl-servers-unsubscribe", (event) => unsubscribeWsl(event.sender.id))
ipcMain.handle("wsl-servers-get-state", () => deps.getWslServersState())
ipcMain.handle("wsl-servers-probe-runtime", () => deps.wslServersProbeRuntime())
ipcMain.handle("wsl-servers-refresh-distros", () => deps.wslServersRefreshDistros())
ipcMain.handle("wsl-servers-install-wsl", () => deps.wslServersInstallWsl())
ipcMain.handle("wsl-servers-install-distro", (_event: IpcMainInvokeEvent, name: string) =>
deps.wslServersInstallDistro(requireString("distro", name)),
)
ipcMain.handle("wsl-servers-probe-distro", (_event: IpcMainInvokeEvent, name: string) =>
deps.wslServersProbeDistro(requireString("distro", name)),
)
ipcMain.handle("wsl-servers-probe-opencode", (_event: IpcMainInvokeEvent, name: string) =>
deps.wslServersProbeOpencode(requireString("distro", name)),
)
ipcMain.handle("wsl-servers-install-opencode", (_event: IpcMainInvokeEvent, name: string) =>
deps.wslServersInstallOpencode(requireString("distro", name)),
)
ipcMain.handle("wsl-servers-open-terminal", (_event: IpcMainInvokeEvent, name: string) =>
deps.wslServersOpenTerminal(requireString("distro", name)),
)
ipcMain.handle("wsl-servers-add", (_event: IpcMainInvokeEvent, distro: string) =>
deps.wslServersAddServer(requireString("distro", distro)),
)
ipcMain.handle("wsl-servers-remove", (_event: IpcMainInvokeEvent, id: string) =>
deps.wslServersRemoveServer(requireString("server id", id)),
)
ipcMain.handle("wsl-servers-start", (_event: IpcMainInvokeEvent, id: string) =>
deps.wslServersStartServer(requireString("server id", id)),
)
ipcMain.handle("get-window-config", () => deps.getWindowConfig())
ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks())
ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl())
ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) =>
deps.setDefaultServerUrl(url),
)
ipcMain.handle("get-wsl-config", () => deps.getWslConfig())
ipcMain.handle("set-wsl-config", (_event: IpcMainInvokeEvent, config: WslConfig) => deps.setWslConfig(config))
ipcMain.handle("get-display-backend", () => deps.getDisplayBackend())
ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) =>
deps.setDisplayBackend(backend),
)
ipcMain.handle("parse-markdown", (_event: IpcMainInvokeEvent, markdown: string) => deps.parseMarkdown(markdown))
ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName))
ipcMain.handle(
"wsl-path",
(_event: IpcMainInvokeEvent, path: string, mode: "windows" | "linux" | null, distro?: string | null) =>
deps.wslPath(path, mode, distro),
ipcMain.handle("wsl-path", (_event: IpcMainInvokeEvent, path: string, mode: "windows" | "linux" | null) =>
deps.wslPath(path, mode),
)
ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
ipcMain.on("loading-window-complete", () => deps.loadingWindowComplete())
@@ -254,7 +178,8 @@ export function registerIpcHandlers(deps: Deps) {
})
ipcMain.on("relaunch", () => {
deps.relaunch()
app.relaunch()
app.exit(0)
})
ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor())

View File

@@ -1,11 +1,9 @@
import { spawn } from "node:child_process"
import { randomUUID } from "node:crypto"
import { createServer } from "node:net"
import { app } from "electron"
import { DEFAULT_SERVER_URL_KEY } from "./constants"
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
import { getUserShell, loadShellEnv } from "./shell-env"
import { getStore } from "./store"
import { type WslCommandLine, resolveWslOpencode, shellEscape, wslArgs } from "./wsl"
export type WslConfig = { enabled: boolean }
export type HealthCheck = { wait: Promise<void> }
@@ -23,26 +21,13 @@ export function setDefaultServerUrl(url: string | null) {
getStore().delete(DEFAULT_SERVER_URL_KEY)
}
export async function allocatePort() {
const fromEnv = process.env.OPENCODE_PORT
if (fromEnv) {
const parsed = Number.parseInt(fromEnv, 10)
if (!Number.isNaN(parsed)) return parsed
}
return new Promise<number>((resolve, reject) => {
const server = createServer()
server.on("error", reject)
server.listen(0, "127.0.0.1", () => {
const address = server.address()
if (typeof address !== "object" || !address) {
server.close()
reject(new Error("Failed to get port"))
return
}
const port = address.port
server.close(() => resolve(port))
})
})
export function getWslConfig(): WslConfig {
const value = getStore().get(WSL_ENABLED_KEY)
return { enabled: typeof value === "boolean" ? value : false }
}
export function setWslConfig(config: WslConfig) {
getStore().set(WSL_ENABLED_KEY, config.enabled)
}
export async function spawnLocalServer(hostname: string, port: number, password: string) {
@@ -73,133 +58,6 @@ export async function spawnLocalServer(hostname: string, port: number, password:
return { listener, health: { wait } }
}
export type WslSidecar = {
listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void }
url: string
username: string | null
password: string
}
export async function spawnWslSidecar(
distro: string,
opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {},
): Promise<WslSidecar> {
// Do not pass --user here: the sidecar should inherit the distro's
// default user so config, auth, git, ssh, and file ownership match the
// user's normal WSL environment. If that default user is root, WSL will
// choose root itself.
const opencode = await resolveWslOpencode(distro)
if (!opencode) throw new Error(`OpenCode is not installed in ${distro}`)
const port = await allocatePort()
const password = randomUUID()
const username = "opencode"
const logLevel = app.isPackaged ? "WARN" : "INFO"
const script = [
"set -euo pipefail",
// wsl.exe inherits the Windows-side cwd (e.g. C:\Users\Lukem) and maps it
// to the distro as /mnt/c/Users/Lukem — a DrvFs/9p path. opencode's
// instance middleware falls back to `process.cwd()` when a request
// arrives without a `directory=` query or `x-opencode-directory` header
// (see opencode server.ts InstanceMiddleware), and then calls
// `realpathSync(process.cwd())` synchronously on the main thread. A
// statx against a 9p path can wedge the whole event loop in kernel
// uninterruptible sleep, freezing the accept loop. Move cwd to the
// user's native Linux home so the fallback can't land on DrvFs.
'cd "$HOME" || cd /',
// wsl.exe by default splices the Windows %PATH% into the distro's $PATH
// via the interop layer (every `/mnt/c/Program Files/...` entry). Anything
// the sidecar spawns — PTY login shells, plugin helpers, etc. — then
// inherits it, which means `which pwsh.exe` resolves to the Windows
// PowerShell binary and bash-l profiles that end with
// eval "$(oh-my-posh init bash)" (or similar)
// silently run Windows pwsh for prompt rendering, whose banner
// ("Loading personal and system profiles took Xms.") then shows up in
// opencode's terminal pane. We want a clean, Linux-only environment in
// the sidecar, so filter every /mnt/* segment out of PATH and clear
// WSLENV so no further Windows vars leak in. Users who really need
// Windows binaries in the sidecar can invoke them by absolute path.
'PATH=$(awk -v RS=: -v ORS=: \'$0 !~ /^\\/mnt\\//\' <<<"$PATH" | sed "s/:$//")',
"export PATH",
"export WSLENV=",
// WSL sidecars often target /mnt/* worktrees. Keep the desktop-only
// watcher/discovery features off there because DrvFs/9p stalls can wedge
// the server process after it starts listening.
"export OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER=true",
"export OPENCODE_CLIENT=desktop",
`export OPENCODE_SERVER_USERNAME=${shellEscape(username)}`,
`export OPENCODE_SERVER_PASSWORD=${shellEscape(password)}`,
'export XDG_STATE_HOME="$HOME/.local/state"',
`exec ${shellEscape(opencode)} --print-logs --log-level ${logLevel} serve --hostname 0.0.0.0 --port ${port}`,
].join("\n")
const child = spawn("wsl", wslArgs(["bash", "-se"], distro), {
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
})
child.stdin.end(script)
let settled = false
const recentOutput: string[] = []
const emit = (line: WslCommandLine) => {
if (settled || !line.text.trim()) return
recentOutput.push(`[${line.stream}] ${line.text}`)
if (recentOutput.length > 12) recentOutput.shift()
opts.onLine?.(line)
}
forwardLines(child.stdout, "stdout", emit)
forwardLines(child.stderr, "stderr", emit)
const exit = new Promise<never>((_, reject) => {
child.once("error", reject)
child.once("exit", (code, signal) => {
reject(new Error(startupFailure(code, signal, recentOutput)))
})
})
const url = `http://127.0.0.1:${port}`
const healthPromise = (async () => {
while (true) {
await new Promise((resolve) => setTimeout(resolve, 100))
if (await checkHealth(url, password)) return
}
})()
const timeoutMs = opts.healthTimeoutMs ?? 30_000
const timeout = new Promise<never>((_, reject) => {
const id = setTimeout(
() => reject(new Error(`Sidecar for ${distro} health check timed out after ${timeoutMs}ms`)),
timeoutMs,
)
void healthPromise.finally(() => clearTimeout(id))
})
try {
await Promise.race([healthPromise, exit, timeout])
} catch (error) {
child.kill()
throw error
} finally {
settled = true
}
return {
listener: {
stop() {
child.kill()
},
onExit(cb) {
child.once("exit", cb)
},
},
url,
username,
password,
}
}
function prepareServerEnv(password: string) {
const shell = process.platform === "win32" ? null : getUserShell()
const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {}
@@ -216,29 +74,6 @@ function prepareServerEnv(password: string) {
Object.assign(process.env, env)
}
function forwardLines(
stream: NodeJS.ReadableStream,
source: WslCommandLine["stream"],
onLine: (line: WslCommandLine) => void,
) {
let pending = ""
stream.setEncoding("utf8")
stream.on("data", (chunk: string) => {
pending += chunk
const lines = pending.split(/\r?\n/g)
pending = lines.pop() ?? ""
for (const line of lines) onLine({ stream: source, text: line })
})
stream.on("end", () => {
if (pending) onLine({ stream: source, text: pending })
})
}
function startupFailure(code: number | null, signal: NodeJS.Signals | null, recentOutput: string[]) {
const suffix = recentOutput.length ? `\n${recentOutput.join("\n")}` : ""
return `WSL server exited before becoming healthy (code=${code ?? "null"} signal=${signal ?? "null"})${suffix}`
}
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
let healthUrl: URL
try {

View File

@@ -1,447 +0,0 @@
import type {
WslDistroProbe,
WslInstalledDistro,
WslJob,
WslOnlineDistro,
WslOpencodeCheck,
WslRuntimeCheck,
WslServerConfig,
WslServerItem,
WslServerRuntime,
WslServersEvent,
WslServersState,
} from "../preload/types"
import { WSL_SERVERS_KEY } from "./constants"
import { getStore } from "./store"
import {
installWslDistro,
installWslOpencode,
installWslRuntimeElevated,
listInstalledWslDistros,
listOnlineWslDistros,
openWslTerminal,
probeWslDistro,
probeWslRuntime,
readWslCommandVersion,
resolveWslOpencode,
summarize,
upgradeWslOpencode,
wslNeedsRestart,
} from "./wsl"
type RunningSidecar = {
listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void }
url: string
username: string | null
password: string
}
type SpawnSidecar = (distro: string) => Promise<RunningSidecar>
type ControllerLogger = {
log: (message: string, meta?: unknown) => void
error: (message: string, meta?: unknown) => void
}
export type WslServersController = ReturnType<typeof createWslServersController>
export function wslServerIdForDistro(distro: string) {
return `wsl:${distro}`
}
export function createWslServersController(appVersion: string, spawnSidecar: SpawnSidecar, logger?: ControllerLogger) {
let state: WslServersState = initialState()
const listeners = new Set<(event: WslServersEvent) => void>()
const sidecars = new Map<string, RunningSidecar>()
const startAttempts = new Map<string, number>()
let jobAbort: AbortController | undefined
const emit = () => {
for (const listener of listeners) listener({ type: "state", state })
}
const setState = (next: Partial<WslServersState>) => {
state = { ...state, ...next }
emit()
}
const persistServers = (servers: WslServerConfig[]) => {
getStore().set(WSL_SERVERS_KEY, { servers })
}
const updateServer = (id: string, update: (item: WslServerItem) => WslServerItem) => {
const next = state.servers.map((item) => (item.config.id === id ? update(item) : item))
setState({ servers: next })
}
const beginJob = (job: WslJob): AbortController => {
jobAbort?.abort()
const abort = new AbortController()
jobAbort = abort
setState({ job })
return abort
}
const endJob = (abort: AbortController) => {
if (jobAbort !== abort) return
jobAbort = undefined
setState({ job: null })
}
const refreshFromStore = () => {
const persisted = readPersistedServers()
const items: WslServerItem[] = persisted.map((config) => {
const existing = state.servers.find((item) => item.config.id === config.id)
return {
config,
runtime: existing?.runtime ?? { kind: "stopped" },
}
})
setState({ servers: items })
}
const setRuntime = (id: string, runtime: WslServerRuntime) => {
updateServer(id, (item) => ({ ...item, runtime }))
}
const setOpencodeCheck = (distro: string, check: WslOpencodeCheck) => {
setState({
opencodeChecks: {
...state.opencodeChecks,
[distro]: check,
},
})
}
const refreshOpencodeCheck = async (distro: string, opts?: { signal?: AbortSignal }) => {
const resolved = await resolveWslOpencode(distro, opts)
const version = resolved ? await readWslCommandVersion(resolved, distro, opts) : null
setOpencodeCheck(distro, opencodeCheck(distro, resolved, version, appVersion))
}
const refreshDistroLists = async (opts: { signal?: AbortSignal }) => {
const [installed, online] = await Promise.all([
listInstalledWslDistros(opts),
listOnlineWslDistros(opts),
])
return { installed, online }
}
const nextStartAttempt = (id: string) => {
const next = (startAttempts.get(id) ?? 0) + 1
startAttempts.set(id, next)
return next
}
const invalidateStartAttempt = (id: string) => {
startAttempts.set(id, (startAttempts.get(id) ?? 0) + 1)
}
const isCurrentStartAttempt = (id: string, attempt: number) => {
return startAttempts.get(id) === attempt && state.servers.some((item) => item.config.id === id)
}
const startServer = async (id: string) => {
const item = state.servers.find((x) => x.config.id === id)
if (!item) return
const attempt = nextStartAttempt(id)
await stopServerInternal(id)
if (!isCurrentStartAttempt(id, attempt)) return
setRuntime(id, { kind: "starting" })
logger?.log("wsl sidecar starting", { id, distro: item.config.distro })
try {
const sidecar = await spawnSidecar(item.config.distro)
if (!isCurrentStartAttempt(id, attempt)) {
try {
sidecar.listener.stop()
} catch {
// ignore stop errors for stale sidecars
}
return
}
sidecars.set(id, sidecar)
setRuntime(id, {
kind: "ready",
url: sidecar.url,
username: sidecar.username,
password: sidecar.password,
})
sidecar.listener.onExit((code, signal) => {
if (sidecars.get(id) !== sidecar) return
sidecars.delete(id)
const message = startupFailure(code, signal)
setRuntime(id, { kind: "failed", message })
logger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal })
})
void refreshOpencodeCheck(item.config.distro).catch((error) => {
const message = error instanceof Error ? error.message : String(error)
logger?.error("wsl opencode check failed", { id, distro: item.config.distro, message })
})
logger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (!isCurrentStartAttempt(id, attempt)) return
setRuntime(id, { kind: "failed", message })
// Without this, an Ubuntu-style silent failure leaves no trace in
// main.log — the controller captures the message in its state but
// nothing surfaces unless the user opens the WSL servers dialog.
logger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message })
}
}
const stopServerInternal = async (id: string) => {
const existing = sidecars.get(id)
if (!existing) return
sidecars.delete(id)
try {
existing.listener.stop()
} catch {
// ignore stop errors
}
}
const runJob = async <T>(job: WslJob, runner: (abort: AbortController) => Promise<T>) => {
const abort = beginJob(job)
try {
const value = await runner(abort)
endJob(abort)
return value
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
endJob(abort)
return undefined
}
const err = error instanceof Error ? error : new Error(String(error))
endJob(abort)
throw err
}
}
return {
getState() {
return state
},
subscribe(listener: (event: WslServersEvent) => void) {
listeners.add(listener)
return () => listeners.delete(listener)
},
async initialize() {
refreshFromStore()
await Promise.all(state.servers.map((item) => startServer(item.config.id)))
},
async probeRuntime() {
await runJob({ kind: "runtime", startedAt: Date.now() }, async (abort) => {
const runtime = await probeWslRuntime({ signal: abort.signal })
setState({
runtime,
pendingRestart: state.pendingRestart && !runtime.available ? state.pendingRestart : false,
})
})
},
async refreshDistros() {
await runJob({ kind: "distros", startedAt: Date.now() }, async (abort) => {
setState(await refreshDistroLists({ signal: abort.signal }))
})
},
async installWsl() {
await runJob({ kind: "install-wsl", startedAt: Date.now() }, async (abort) => {
const result = await installWslRuntimeElevated({ signal: abort.signal })
if (result.code !== 0) {
const message = summarize(result.stderr || result.stdout) || "WSL installation failed"
throw new Error(message)
}
const pendingRestart = wslNeedsRestart(result)
setState({ pendingRestart })
if (!pendingRestart) {
const runtime = await probeWslRuntime({ signal: abort.signal })
setState({ runtime })
}
})
},
async installDistro(name: string) {
await runJob({ kind: "install-distro", distro: name, startedAt: Date.now() }, async (abort) => {
const result = await installWslDistro(name, { signal: abort.signal })
if (result.code !== 0) {
const message = summarize(result.stderr || result.stdout) || `Failed to install distro: ${name}`
throw new Error(message)
}
const distros = await refreshDistroLists({ signal: abort.signal })
const probe = await probeWslDistro(name, { signal: abort.signal })
setState({
...distros,
distroProbes: { ...state.distroProbes, [name]: probe },
})
})
},
async probeDistro(name: string) {
await runJob({ kind: "probe-distro", distro: name, startedAt: Date.now() }, async (abort) => {
const probe = await probeWslDistro(name, { signal: abort.signal })
setState({ distroProbes: { ...state.distroProbes, [name]: probe } })
})
},
async probeOpencode(name: string) {
await runJob({ kind: "probe-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
await refreshOpencodeCheck(name, { signal: abort.signal })
})
},
async installOpencode(name: string) {
await runJob({ kind: "install-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
const resolved = await resolveWslOpencode(name, { signal: abort.signal })
const existingVersion = resolved
? await readWslCommandVersion(resolved, name, { signal: abort.signal })
: null
const result =
resolved && existingVersion
? await upgradeWslOpencode(appVersion, resolved, name, { signal: abort.signal })
: await installWslOpencode(appVersion, name, { signal: abort.signal })
if (result.code !== 0) {
throw new Error(summarize(result.stderr || result.stdout) || "OpenCode installation failed")
}
await refreshOpencodeCheck(name, { signal: abort.signal })
})
},
async openTerminal(name: string) {
await openWslTerminal(name)
},
async addServer(distro: string): Promise<WslServerConfig> {
const id = wslServerIdForDistro(distro)
if (state.servers.some((item) => item.config.id === id)) {
throw new Error(`${distro} is already added`)
}
const config: WslServerConfig = {
id,
distro,
}
persistServers([...readPersistedServers(), config])
setState({
servers: [...state.servers, { config, runtime: { kind: "starting" } }],
})
void startServer(id)
return config
},
async removeServer(id: string) {
invalidateStartAttempt(id)
await stopServerInternal(id)
const remaining = readPersistedServers().filter((item) => item.id !== id)
persistServers(remaining)
setState({ servers: state.servers.filter((item) => item.config.id !== id) })
},
startServer,
stopAll() {
for (const item of state.servers) invalidateStartAttempt(item.config.id)
for (const existing of sidecars.values()) {
try {
existing.listener.stop()
} catch {
// ignore
}
}
sidecars.clear()
},
}
}
function initialState(): WslServersState {
return {
runtime: null,
installed: [],
online: [],
distroProbes: {},
opencodeChecks: {},
pendingRestart: false,
servers: [],
job: null,
}
}
function readPersistedServers(): WslServerConfig[] {
const store = getStore()
const existing = store.get(WSL_SERVERS_KEY)
if (existing && typeof existing === "object") {
const record = existing as { servers?: unknown }
const list = Array.isArray(record.servers) ? record.servers : []
return list.flatMap(normalizePersistedServer)
}
return []
}
function normalizePersistedServer(value: unknown): WslServerConfig[] {
if (!value || typeof value !== "object") return []
const record = value as Record<string, unknown>
const distro = typeof record.distro === "string" && record.distro.length > 0 ? record.distro : null
if (!distro) return []
const id = typeof record.id === "string" && record.id.length > 0 ? record.id : wslServerIdForDistro(distro)
return [
{
id,
distro,
},
]
}
function opencodeCheck(
distro: string,
resolvedPath: string | null,
version: string | null,
expectedVersion: string,
): WslOpencodeCheck {
if (!resolvedPath) {
return {
distro,
resolvedPath: null,
version: null,
expectedVersion,
matchesDesktop: null,
error: "opencode is not installed in this distro",
}
}
if (!version) {
return {
distro,
resolvedPath,
version: null,
expectedVersion,
matchesDesktop: null,
error: "opencode is installed but could not run",
}
}
return {
distro,
resolvedPath,
version,
expectedVersion,
matchesDesktop: version === expectedVersion,
error: null,
}
}
function startupFailure(code: number | null, signal: NodeJS.Signals | null) {
return `WSL server exited after startup (code=${code ?? "null"} signal=${signal ?? "null"})`
}
// Re-export types used by callers
export type {
WslInstalledDistro,
WslOnlineDistro,
WslRuntimeCheck,
WslDistroProbe,
WslOpencodeCheck,
WslServerConfig,
WslServerItem,
WslServerRuntime,
WslServersEvent,
WslServersState,
}

View File

@@ -1,423 +0,0 @@
import { spawn } from "node:child_process"
import { existsSync } from "node:fs"
import { join } from "node:path"
/** @ts-expect-error */
import * as pty from "@lydell/node-pty"
import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../preload/types"
export type WslCommandLine = {
stream: "stdout" | "stderr"
text: string
}
export type WslCommandResult = {
code: number | null
signal: NodeJS.Signals | null
stdout: string
stderr: string
}
export type RunWslOptions = {
signal?: AbortSignal
/**
* Ceiling on how long we wait for the child process to exit. When the
* LXSS service or a specific distro wedges (e.g. Ubuntu-24.04 with a
* pending first-run prompt), `wsl.exe` never returns and any command
* that doesn't specify a timeout hangs the entire startup flow. Default
* is 20s — enough for slow cold-starts, short enough to fail fast on
* a wedge. Callers can override for longer-running jobs.
*/
timeoutMs?: number
}
const DEFAULT_WSL_TIMEOUT_MS = 20_000
const DEFAULT_WSL_INSTALL_TIMEOUT_MS = 15 * 60_000
export function wslArgs(args: string[], distro?: string | null) {
if (distro) return ["-d", distro, "--", ...args]
return ["--", ...args]
}
export function runWsl(args: string[], opts: RunWslOptions = {}) {
return runCommand("wsl", args, opts)
}
function runPowerShell(command: string, opts: RunWslOptions = {}) {
return runCommand(
"powershell.exe",
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", command],
opts,
)
}
function runCommand(command: string, args: string[], opts: RunWslOptions = {}) {
return new Promise<WslCommandResult>((resolve, reject) => {
const child = spawn(command, args, {
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
signal: opts.signal,
})
// Guard every wsl.exe invocation with a timeout. When the distro or
// the LXSS service is wedged (Ubuntu first-run state, Windows update
// pending, etc.) wsl.exe produces no output and never exits; without
// this the whole sidecar spawn flow stalls the app forever.
const timeoutMs = opts.timeoutMs ?? DEFAULT_WSL_TIMEOUT_MS
const timeoutId = setTimeout(() => {
try {
child.kill()
} catch {
/* ignore */
}
reject(new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`))
}, timeoutMs)
let stdout = ""
let stderr = ""
const stdoutDecoder = createOutputDecoder()
const stderrDecoder = createOutputDecoder()
const append = (stream: WslCommandLine["stream"], chunk: string) => {
if (!chunk) return
if (stream === "stdout") {
stdout += chunk
return
}
stderr += chunk
}
child.stdout.on("data", (chunk: Buffer) => {
append("stdout", stdoutDecoder.decode(chunk))
})
child.stdout.on("end", () => {
append("stdout", stdoutDecoder.flush())
})
child.stderr.on("data", (chunk: Buffer) => {
append("stderr", stderrDecoder.decode(chunk))
})
child.stderr.on("end", () => {
append("stderr", stderrDecoder.flush())
})
child.once("error", (error) => {
clearTimeout(timeoutId)
reject(error)
})
child.once("close", (code, signal) => {
clearTimeout(timeoutId)
resolve({ code, signal, stdout, stderr })
})
})
}
function runInteractiveCommand(command: string, args: string[], opts: RunWslOptions = {}, defaultTimeoutMs: number) {
return new Promise<WslCommandResult>((resolve, reject) => {
const child = pty.spawn(command, args, {
name: "xterm-color",
cols: 80,
rows: 24,
cwd: process.cwd(),
env: process.env,
useConpty: true,
})
let settled = false
let stdout = ""
const cleanup = () => {
clearTimeout(timeoutId)
abortCleanup?.()
}
const timeoutMs = opts.timeoutMs ?? defaultTimeoutMs
const timeoutId = setTimeout(() => {
try {
child.kill()
} catch {
/* ignore */
}
if (settled) return
settled = true
cleanup()
reject(new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`))
}, timeoutMs)
const abortHandler = () => {
try {
child.kill()
} catch {
/* ignore */
}
if (settled) return
settled = true
cleanup()
reject(new DOMException("Aborted", "AbortError"))
}
const abortCleanup = opts.signal
? (() => {
opts.signal?.addEventListener("abort", abortHandler, { once: true })
return () => opts.signal?.removeEventListener("abort", abortHandler)
})()
: undefined
child.onData((data: string) => {
stdout += data
})
child.onExit((event: { exitCode: number }) => {
if (settled) return
settled = true
cleanup()
resolve({ code: event.exitCode, signal: null, stdout, stderr: "" })
})
})
}
function createOutputDecoder() {
let decoder: TextDecoder | undefined
return {
decode(chunk: Buffer) {
decoder ??= new TextDecoder(detectOutputEncoding(chunk))
return decoder.decode(chunk, { stream: true })
},
flush() {
return decoder?.decode() ?? ""
},
}
}
function detectOutputEncoding(chunk: Uint8Array) {
if (chunk[0] === 0xff && chunk[1] === 0xfe) return "utf-16le"
const pairs = Math.floor(chunk.length / 2)
if (pairs < 2) return "utf-8"
const oddZeroes = Array.from({ length: pairs }).filter((_, index) => chunk[index * 2 + 1] === 0).length
const evenZeroes = Array.from({ length: pairs }).filter((_, index) => chunk[index * 2] === 0).length
return oddZeroes >= Math.ceil(pairs / 3) && evenZeroes * 2 <= oddZeroes ? "utf-16le" : "utf-8"
}
export function runWslInDistro(args: string[], distro?: string | null, opts?: RunWslOptions) {
return runWsl(wslArgs(args, distro), opts)
}
export function runWslSh(script: string, distro?: string | null, opts?: RunWslOptions) {
return runWslInDistro(["sh", "-lc", script], distro, opts)
}
export async function probeWslRuntime(opts?: RunWslOptions): Promise<WslRuntimeCheck> {
const version = await runWsl(["--version"], opts).catch((error) => ({
code: 1,
signal: null,
stdout: "",
stderr: error instanceof Error ? error.message : String(error),
}))
if (version.code !== 0) {
return {
available: false,
version: null,
error: summarize(version.stderr || version.stdout) || "WSL is unavailable",
}
}
return {
available: true,
version: firstLine(version.stdout),
error: null,
}
}
export async function listInstalledWslDistros(opts?: RunWslOptions) {
const result = await runWsl(["--list", "--verbose"], opts)
if (result.code !== 0) {
throw new Error(summarize(result.stderr || result.stdout) || "Failed to list installed WSL distros")
}
return parseInstalledDistros(result.stdout)
}
export async function listOnlineWslDistros(opts?: RunWslOptions) {
const result = await runWsl(["--list", "--online"], opts)
if (result.code !== 0) {
throw new Error(summarize(result.stderr || result.stdout) || "Failed to list online WSL distros")
}
return parseOnlineDistros(result.stdout)
}
export async function installWslRuntimeElevated(opts?: RunWslOptions) {
const script = [
"$ErrorActionPreference = 'Stop'",
"$process = Start-Process -FilePath 'wsl.exe' -Verb RunAs -ArgumentList @('--install','--no-distribution') -Wait -PassThru",
"if ($null -ne $process.ExitCode) { exit $process.ExitCode }",
].join("; ")
return runPowerShell(script, withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS))
}
export async function installWslDistro(name: string, opts?: RunWslOptions) {
return runInteractiveCommand(
resolveSystem32Command("wsl.exe"),
["--install", "-d", name, "--web-download", "--no-launch"],
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
)
}
export async function installWslOpencode(version: string, distro: string, opts?: RunWslOptions) {
return runInteractiveCommand(
resolveSystem32Command("wsl.exe"),
wslArgs(["bash", "-lc", `curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)}`], distro),
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
)
}
export function wslNeedsRestart(result: WslCommandResult) {
return /restart|reboot/i.test(`${result.stdout}\n${result.stderr}`)
}
export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise<WslDistroProbe> {
const executable = await runWslInDistro(["/bin/true"], name, opts).catch((error) => ({
code: 1,
signal: null,
stdout: "",
stderr: error instanceof Error ? error.message : String(error),
}))
if (executable.code !== 0) {
return {
name,
canExecute: false,
hasBash: false,
hasCurl: false,
error: summarize(executable.stderr || executable.stdout) || "Cannot execute commands in distro",
}
}
const [bash, curl] = await Promise.all([
runWslSh("command -v bash >/dev/null && printf yes || printf no", name, opts),
runWslSh("command -v curl >/dev/null && printf yes || printf no", name, opts),
])
return {
name,
canExecute: true,
hasBash: bash.code === 0 && summarize(bash.stdout) === "yes",
hasCurl: curl.code === 0 && summarize(curl.stdout) === "yes",
error: null,
}
}
export async function resolveWslHome(distro?: string | null, opts?: RunWslOptions) {
return firstLine((await runWslSh('printf "%s\\n" "$HOME"', distro, opts)).stdout) ?? "/"
}
export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) {
const command = firstLine((await runWslSh("command -v opencode 2>/dev/null | grep -v '^/mnt/' | head -n 1 || true", distro, opts)).stdout)
if (command) return command
for (const candidate of [
'if [ -x "${XDG_BIN_DIR:-$HOME/.local/bin}/opencode" ]; then printf "%s\\n" "${XDG_BIN_DIR:-$HOME/.local/bin}/opencode"; fi',
'if [ -x "$HOME/bin/opencode" ]; then printf "%s\\n" "$HOME/bin/opencode"; fi',
'if [ -x "$HOME/.opencode/bin/opencode" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode"; fi',
'if [ -x "/usr/local/bin/opencode" ]; then printf "%s\\n" "/usr/local/bin/opencode"; fi',
]) {
const resolved = firstLine((await runWslSh(candidate, distro, opts)).stdout)
if (resolved) return resolved
}
return null
}
export async function readWslCommandVersion(command: string, distro: string, opts?: RunWslOptions) {
const result = await runWslSh(`${shellEscape(command)} --version 2>/dev/null || true`, distro, opts)
return firstLine(result.stdout)
}
export async function upgradeWslOpencode(target: string, command: string, distro: string, opts?: RunWslOptions) {
return runInteractiveCommand(
resolveSystem32Command("wsl.exe"),
wslArgs(["bash", "-lc", `${shellEscape(command)} upgrade ${shellEscape(target)}`], distro),
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
)
}
export function openWslTerminal(distro?: string | null) {
if (distro && !/^[a-zA-Z0-9_.-]+$/.test(distro)) {
return Promise.reject(new Error("Invalid distro name"))
}
return new Promise<void>((resolve, reject) => {
const child = spawn("cmd.exe", ["/c", "start", "", "wsl", ...(distro ? ["-d", distro] : [])], {
detached: true,
stdio: "ignore",
windowsHide: true,
})
child.once("error", reject)
child.once("spawn", () => {
child.unref()
resolve()
})
})
}
function parseInstalledDistros(output: string) {
return output.split(/\r?\n/g).flatMap((line) => {
const trimmed = line.trim()
if (!trimmed) return []
const match = line.match(/^\s*(\*)?\s*(.*?)\s{2,}\S+\s+(\d+)\s*$/)
if (!match) return []
const [, marker, name, version] = match
if (!name || /^name$/i.test(name)) return []
return [
{
name: name.trim(),
version: Number.isNaN(Number.parseInt(version, 10)) ? null : Number.parseInt(version, 10),
isDefault: marker === "*",
} satisfies WslInstalledDistro,
]
})
}
function parseOnlineDistros(output: string) {
return output.split(/\r?\n/g).flatMap((line) => {
const trimmed = line.trim()
if (!trimmed) return []
const match = trimmed.match(/^([A-Za-z0-9._-]+)\s{2,}(.+)$/)
if (!match) return []
const [, name, label] = match
if (/^name$/i.test(name)) return []
return [{ name, label: label.trim() } satisfies WslOnlineDistro]
})
}
function firstLine(value: string) {
return (
value
.split(/\r?\n/g)
.map((line) => line.trim())
.find(Boolean) ?? null
)
}
export function summarize(value: string) {
return value
.split(/\r?\n/g)
.map((line) => line.trim())
.filter(Boolean)
.join("\n")
}
export function shellEscape(value: string) {
return `'${value.replace(/'/g, `'"'"'`)}'`
}
function resolveSystem32Command(command: string) {
const root = process.env.SystemRoot ?? process.env.windir
if (!root) return command
const resolved = join(root, "System32", command)
return existsSync(resolved) ? resolved : command
}
function withTimeout(opts: RunWslOptions | undefined, timeoutMs: number): RunWslOptions {
return {
...opts,
timeoutMs: opts?.timeoutMs ?? timeoutMs,
}
}

View File

@@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer } from "electron"
import type { ElectronAPI, InitStep, SqliteMigrationProgress, WslServersEvent } from "./types"
import type { ElectronAPI, InitStep, SqliteMigrationProgress } from "./types"
const api: ElectronAPI = {
killSidecar: () => ipcRenderer.invoke("kill-sidecar"),
@@ -11,38 +11,17 @@ const api: ElectronAPI = {
ipcRenderer.removeListener("init-step", handler)
})
},
wslServers: {
getState: () => ipcRenderer.invoke("wsl-servers-get-state"),
subscribe: (cb) => {
const handler = (_: unknown, event: WslServersEvent) => cb(event)
ipcRenderer.on("wsl-servers-event", handler)
void ipcRenderer.invoke("wsl-servers-subscribe")
return () => {
ipcRenderer.removeListener("wsl-servers-event", handler)
void ipcRenderer.invoke("wsl-servers-unsubscribe")
}
},
probeRuntime: () => ipcRenderer.invoke("wsl-servers-probe-runtime"),
refreshDistros: () => ipcRenderer.invoke("wsl-servers-refresh-distros"),
installWsl: () => ipcRenderer.invoke("wsl-servers-install-wsl"),
installDistro: (name) => ipcRenderer.invoke("wsl-servers-install-distro", name),
probeDistro: (name) => ipcRenderer.invoke("wsl-servers-probe-distro", name),
probeOpencode: (name) => ipcRenderer.invoke("wsl-servers-probe-opencode", name),
installOpencode: (name) => ipcRenderer.invoke("wsl-servers-install-opencode", name),
openTerminal: (name) => ipcRenderer.invoke("wsl-servers-open-terminal", name),
addServer: (distro) => ipcRenderer.invoke("wsl-servers-add", distro),
removeServer: (id) => ipcRenderer.invoke("wsl-servers-remove", id),
startServer: (id) => ipcRenderer.invoke("wsl-servers-start", id),
},
getWindowConfig: () => ipcRenderer.invoke("get-window-config"),
consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"),
getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url),
getWslConfig: () => ipcRenderer.invoke("get-wsl-config"),
setWslConfig: (config) => ipcRenderer.invoke("set-wsl-config", config),
getDisplayBackend: () => ipcRenderer.invoke("get-display-backend"),
setDisplayBackend: (backend) => ipcRenderer.invoke("set-display-backend", backend),
parseMarkdownCommand: (markdown) => ipcRenderer.invoke("parse-markdown", markdown),
checkAppExists: (appName) => ipcRenderer.invoke("check-app-exists", appName),
wslPath: (path, mode, distro) => ipcRenderer.invoke("wsl-path", path, mode, distro),
wslPath: (path, mode) => ipcRenderer.invoke("wsl-path", path, mode),
resolveAppPath: (appName) => ipcRenderer.invoke("resolve-app-path", appName),
storeGet: (name, key) => ipcRenderer.invoke("store-get", name, key),
storeSet: (name, key, value) => ipcRenderer.invoke("store-set", name, key, value),

View File

@@ -8,87 +8,7 @@ export type ServerReadyData = {
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
export type WslRuntimeCheck = {
available: boolean
version: string | null
error: string | null
}
export type WslInstalledDistro = {
name: string
version: number | null
isDefault: boolean
}
export type WslOnlineDistro = {
name: string
label: string
}
export type WslDistroProbe = {
name: string
canExecute: boolean
hasBash: boolean
hasCurl: boolean
error: string | null
}
export type WslOpencodeCheck = {
distro: string
resolvedPath: string | null
version: string | null
expectedVersion: string | null
matchesDesktop: boolean | null
error: string | null
}
export type WslServerConfig = {
id: string
distro: string
}
export type WslServerRuntime =
| { kind: "starting" }
| { kind: "ready"; url: string; username: string | null; password: string | null }
| { kind: "failed"; message: string }
| { kind: "stopped" }
export type WslServerItem = {
config: WslServerConfig
runtime: WslServerRuntime
}
export type WslJob =
| { kind: "runtime"; startedAt: number }
| { kind: "distros"; startedAt: number }
| { kind: "install-wsl"; startedAt: number }
| { kind: "install-distro"; distro: string; startedAt: number }
| { kind: "probe-distro"; distro: string; startedAt: number }
| { kind: "probe-opencode"; distro: string; startedAt: number }
| { kind: "install-opencode"; distro: string; startedAt: number }
export type WslServersState = {
runtime: WslRuntimeCheck | null
installed: WslInstalledDistro[]
online: WslOnlineDistro[]
distroProbes: Record<string, WslDistroProbe>
opencodeChecks: Record<string, WslOpencodeCheck>
pendingRestart: boolean
servers: WslServerItem[]
job: WslJob | null
}
export type WslServersEvent = { type: "state"; state: WslServersState }
export type WslServersAPI = {
getState: () => Promise<WslServersState>
subscribe: (cb: (event: WslServersEvent) => void) => () => void
probeRuntime: () => Promise<void>
refreshDistros: () => Promise<void>
installWsl: () => Promise<void>
installDistro: (name: string) => Promise<void>
probeDistro: (name: string) => Promise<void>
probeOpencode: (name: string) => Promise<void>
installOpencode: (name: string) => Promise<void>
openTerminal: (name: string) => Promise<void>
addServer: (distro: string) => Promise<WslServerConfig>
removeServer: (id: string) => Promise<void>
startServer: (id: string) => Promise<void>
}
export type WslConfig = { enabled: boolean }
export type LinuxDisplayBackend = "wayland" | "auto"
export type TitlebarTheme = {
@@ -103,16 +23,17 @@ export type ElectronAPI = {
killSidecar: () => Promise<void>
installCli: () => Promise<string>
awaitInitialization: (onStep: (step: InitStep) => void) => Promise<ServerReadyData>
wslServers: WslServersAPI
getWindowConfig: () => Promise<WindowConfig>
consumeInitialDeepLinks: () => Promise<string[]>
getDefaultServerUrl: () => Promise<string | null>
setDefaultServerUrl: (url: string | null) => Promise<void>
getWslConfig: () => Promise<WslConfig>
setWslConfig: (config: WslConfig) => Promise<void>
getDisplayBackend: () => Promise<LinuxDisplayBackend | null>
setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise<void>
parseMarkdownCommand: (markdown: string) => Promise<string>
checkAppExists: (appName: string) => Promise<boolean>
wslPath: (path: string, mode: "windows" | "linux" | null, distro?: string | null) => Promise<string>
wslPath: (path: string, mode: "windows" | "linux" | null) => Promise<string>
resolveAppPath: (appName: string) => Promise<string | null>
storeGet: (name: string, key: string) => Promise<string | null>
storeSet: (name: string, key: string, value: string) => Promise<void>

View File

@@ -5,7 +5,6 @@ declare global {
api: ElectronAPI
__OPENCODE__?: {
deepLinks?: string[]
activeServer?: string
}
}
}

View File

@@ -13,18 +13,16 @@ import {
PlatformProvider,
ServerConnection,
useCommand,
useWslServers,
} from "@opencode-ai/app"
import * as Sentry from "@sentry/solid"
import type { AsyncStorage } from "@solid-primitives/storage"
import { MemoryRouter } from "@solidjs/router"
import { createEffect, createMemo, createResource, onCleanup, onMount } from "solid-js"
import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js"
import { render } from "solid-js/web"
import pkg from "../../package.json"
import { initI18n, t } from "./i18n"
import { webviewZoom } from "./webview-zoom"
import "./styles.css"
import { Splash } from "@opencode-ai/ui/logo"
import { useTheme } from "@opencode-ai/ui/theme"
const root = document.getElementById("root")
@@ -77,26 +75,25 @@ const createPlatform = (): Platform => {
return undefined
})()
const activeWslDistro = () => {
const key = window.__OPENCODE__?.activeServer
if (!key || !key.startsWith("wsl:")) return undefined
return key.slice("wsl:".length)
const isWslEnabled = async () => {
if (os !== "windows") return false
return window.api
.getWslConfig()
.then((config) => config.enabled)
.catch(() => false)
}
const wslHome = async () => {
const distro = activeWslDistro()
if (!distro) return undefined
return window.api.wslPath("~", "windows", distro)
if (!(await isWslEnabled())) return undefined
return window.api.wslPath("~", "windows").catch(() => undefined)
}
const handleWslPicker = async <T extends string | string[] | null>(result: T): Promise<T> => {
const distro = activeWslDistro()
if (!result || !distro) return result
const convert = (path: string) => window.api.wslPath(path, "linux", distro)
const handleWslPicker = async <T extends string | string[]>(result: T | null): Promise<T | null> => {
if (!result || !(await isWslEnabled())) return result
if (Array.isArray(result)) {
return (await Promise.all(result.map(convert))) as T
return Promise.all(result.map((path) => window.api.wslPath(path, "linux").catch(() => path))) as any
}
return (await convert(result)) as T
return window.api.wslPath(result, "linux").catch(() => result) as any
}
const storage = (() => {
@@ -126,8 +123,6 @@ const createPlatform = (): Platform => {
}
})()
const wslServersApi = os === "windows" ? window.api.wslServers : undefined
return {
platform: "desktop",
os,
@@ -168,8 +163,10 @@ const createPlatform = (): Platform => {
if (os === "windows") {
const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null
const resolvedPath = await (async () => {
const distro = activeWslDistro()
if (distro) return window.api.wslPath(path, "windows", distro)
if (await isWslEnabled()) {
const converted = await window.api.wslPath(path, "windows").catch(() => null)
if (converted) return converted
}
return path
})()
return window.api.openPath(resolvedPath, resolvedApp ?? undefined)
@@ -220,7 +217,16 @@ const createPlatform = (): Platform => {
}
},
fetch,
fetch: (input, init) => {
if (input instanceof Request) return fetch(input)
return fetch(input, init)
},
getWslEnabled: () => isWslEnabled(),
setWslEnabled: async (enabled) => {
await window.api.setWslConfig({ enabled })
},
getDefaultServer: async () => {
const url = await window.api.getDefaultServerUrl().catch(() => null)
@@ -232,8 +238,6 @@ const createPlatform = (): Platform => {
await window.api.setDefaultServerUrl(url)
},
wslServers: wslServersApi,
getDisplayBackend: async () => {
return window.api.getDisplayBackend().catch(() => null)
},
@@ -269,6 +273,7 @@ listenForDeepLinks()
render(() => {
const platform = createPlatform()
const [windowConfig] = createResource(() => window.api.getWindowConfig().catch(() => ({ updaterEnabled: false })))
const loadLocale = async () => {
const current = await platform.storage?.("opencode.global.dat").getItem("language")
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
@@ -283,11 +288,32 @@ render(() => {
const [windowCount] = createResource(() => window.api.getWindowCount())
// Fetch sidecar credentials (available immediately, before health check)
const [sidecar] = createResource(() => window.api.awaitInitialization(() => undefined))
const [defaultServer] = createResource(() => platform.getDefaultServer?.())
const [defaultServer] = createResource(() =>
platform.getDefaultServer?.().then((url) => {
if (url) return ServerConnection.key({ type: "http", http: { url } })
}),
)
const [locale] = createResource(loadLocale)
const servers = () => {
const data = sidecar()
if (!data) return []
const server: ServerConnection.Sidecar = {
displayName: "Local Server",
type: "sidecar",
variant: "base",
http: {
url: data.url,
username: data.username ?? undefined,
password: data.password ?? undefined,
},
}
return [server] as ServerConnection.Any[]
}
function handleClick(e: MouseEvent) {
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
if (link?.href) {
@@ -314,66 +340,6 @@ render(() => {
return null
}
function App() {
const wslServers = useWslServers()
const splash = (
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
)
const ready = createMemo(
() =>
!defaultServer.loading &&
!sidecar.loading &&
!windowCount.loading &&
!locale.loading,
)
const servers = createMemo(() => {
const data = sidecar()
const list: ServerConnection.Any[] = []
if (data) {
list.push({
displayName: "Local Server",
type: "sidecar",
variant: "base",
http: {
url: data.url,
username: data.username ?? undefined,
password: data.password ?? undefined,
},
})
}
for (const item of wslServers.data?.servers ?? []) {
const runtime = item.runtime
if (runtime.kind !== "ready") continue
list.push({
displayName: item.config.distro,
type: "sidecar",
variant: "wsl",
distro: item.config.distro,
http: {
url: runtime.url,
username: runtime.username ?? undefined,
password: runtime.password ?? undefined,
},
})
}
return list
})
if (!ready()) return splash
return (
<AppInterface
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
servers={servers()}
router={MemoryRouter}
>
<Inner />
</AppInterface>
)
}
onMount(() => {
document.addEventListener("click", handleClick)
onCleanup(() => {
@@ -384,7 +350,27 @@ render(() => {
return (
<PlatformProvider value={platform}>
<AppBaseProviders locale={locale.latest}>
<App />
<Show
when={
!defaultServer.loading &&
!sidecar.loading &&
!windowConfig.loading &&
!windowCount.loading &&
!locale.loading
}
>
{(_) => {
return (
<AppInterface
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
servers={servers()}
router={MemoryRouter}
>
<Inner />
</AppInterface>
)
}}
</Show>
</AppBaseProviders>
</PlatformProvider>
)

View File

@@ -26,20 +26,13 @@ const applyZoom = (next: number) => {
window.addEventListener("keydown", (event) => {
if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return
if (event.key === "-") {
event.preventDefault()
applyZoom(clamp(webviewZoom() - 0.2))
return
}
if (event.key === "=" || event.key === "+") {
event.preventDefault()
applyZoom(clamp(webviewZoom() + 0.2))
return
}
if (event.key === "0") {
event.preventDefault()
applyZoom(1)
}
let newZoom = webviewZoom()
if (event.key === "-") newZoom -= 0.2
if (event.key === "=" || event.key === "+") newZoom += 0.2
if (event.key === "0") newZoom = 1
applyZoom(clamp(newZoom))
})
export { webviewZoom }

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.14.33",
"version": "1.14.32",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,8 +1,7 @@
#!/usr/bin/env bun
import { Buffer } from "node:buffer"
import { $ } from "bun"
import path from "node:path"
import { parseArgs } from "node:util"
const { values } = parseArgs({
args: Bun.argv.slice(2),
@@ -13,6 +12,8 @@ const { values } = parseArgs({
const dryRun = values["dry-run"]
import { parseArgs } from "node:util"
const repo = process.env.GH_REPO
if (!repo) throw new Error("GH_REPO is required")
@@ -22,22 +23,20 @@ if (!releaseId) throw new Error("OPENCODE_RELEASE is required")
const version = process.env.OPENCODE_VERSION
if (!version) throw new Error("OPENCODE_VERSION is required")
const dir = process.env.LATEST_YML_DIR
if (!dir) throw new Error("LATEST_YML_DIR is required")
const root = dir
const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN
if (!token) throw new Error("GH_TOKEN or GITHUB_TOKEN is required")
const rel = await fetch(`https://api.github.com/repos/${repo}/releases/${releaseId}`, {
headers: {
Authorization: `token ${token}`,
Accept: "application/vnd.github+json",
},
const apiHeaders = {
Authorization: `token ${token}`,
Accept: "application/vnd.github+json",
}
const releaseRes = await fetch(`https://api.github.com/repos/${repo}/releases/${releaseId}`, {
headers: apiHeaders,
})
if (!rel.ok) {
throw new Error(`Failed to fetch release: ${rel.status} ${rel.statusText}`)
if (!releaseRes.ok) {
throw new Error(`Failed to fetch release: ${releaseRes.status} ${releaseRes.statusText}`)
}
type Asset = {
@@ -46,169 +45,115 @@ type Asset = {
}
type Release = {
tag_name?: string
assets?: Asset[]
}
const assets = ((await rel.json()) as Release).assets ?? []
const amap = new Map(assets.map((item) => [item.name, item]))
const release = (await releaseRes.json()) as Release
const assets = release.assets ?? []
const assetByName = new Map(assets.map((asset) => [asset.name, asset]))
type Item = {
url: string
const latestAsset = assetByName.get("latest.json")
if (!latestAsset) {
console.log("latest.json not found, skipping tauri finalization")
process.exit(0)
}
type Yml = {
version: string
files: Item[]
const latestRes = await fetch(latestAsset.url, {
headers: {
Authorization: `token ${token}`,
Accept: "application/octet-stream",
},
})
if (!latestRes.ok) {
throw new Error(`Failed to fetch latest.json: ${latestRes.status} ${latestRes.statusText}`)
}
function parse(text: string): Yml {
const lines = text.split("\n")
let version = ""
const files: Item[] = []
let url = ""
const latestText = new TextDecoder().decode(await latestRes.arrayBuffer())
const latest = JSON.parse(latestText)
const base = { ...latest }
delete base.platforms
const flush = () => {
if (!url) return
files.push({ url })
url = ""
}
for (const line of lines) {
const trim = line.trim()
if (line.startsWith("version:")) {
version = line.slice("version:".length).trim()
continue
}
if (trim.startsWith("- url:")) {
flush()
url = trim.slice("- url:".length).trim()
continue
}
const indented = line.startsWith(" ") || line.startsWith("\t")
if (!indented) flush()
}
flush()
return { version, files }
}
async function read(sub: string, file: string) {
const item = Bun.file(path.join(root, sub, file))
if (!(await item.exists())) return undefined
return parse(await item.text())
}
function pick(list: Item[], exts: string[]) {
for (const ext of exts) {
const found = list.find((item) => item.url.split("?")[0]?.toLowerCase().endsWith(ext))
if (found) return found.url
}
}
function link(raw: string) {
if (raw.startsWith("https://") || raw.startsWith("http://")) return raw
return `https://github.com/${repo}/releases/download/v${version}/${raw}`
}
async function sign(url: string, key: string) {
const name = decodeURIComponent(new URL(url).pathname.split("/").pop() ?? key)
const asset = amap.get(name)
const res = await fetch(asset?.url ?? url, {
const fetchSignature = async (asset: Asset) => {
const res = await fetch(asset.url, {
headers: {
Authorization: `token ${token}`,
...(asset ? { Accept: "application/octet-stream" } : {}),
Accept: "application/octet-stream",
},
})
if (!res.ok) {
throw new Error(`Failed to fetch file ${name}: ${res.status} ${res.statusText} (${asset?.url ?? url})`)
throw new Error(`Failed to fetch signature: ${res.status} ${res.statusText}`)
}
const tmp = process.env.RUNNER_TEMP ?? "/tmp"
const file = path.join(tmp, name)
await Bun.write(file, await res.arrayBuffer())
await $`bunx @tauri-apps/cli signer sign ${file}`
const sigFile = Bun.file(`${file}.sig`)
if (!(await sigFile.exists())) throw new Error(`Signature file not found for ${name}`)
return (await sigFile.text()).trim()
return Buffer.from(await res.arrayBuffer()).toString()
}
const add = async (data: Record<string, { url: string; signature: string }>, key: string, raw: string | undefined) => {
if (!raw) return
if (data[key]) return
const url = link(raw)
data[key] = { url, signature: await sign(url, key) }
const entries: Record<string, { url: string; signature: string }> = {}
const add = (key: string, asset: Asset, signature: string) => {
if (entries[key]) return
entries[key] = {
url: `https://github.com/${repo}/releases/download/v${version}/${asset.name}`,
signature,
}
}
const alias = (data: Record<string, { url: string; signature: string }>, key: string, src: string) => {
if (data[key]) return
if (!data[src]) return
data[key] = data[src]
const targets = [
{ key: "linux-x86_64-deb", asset: "opencode-desktop-linux-amd64.deb" },
{ key: "linux-x86_64-rpm", asset: "opencode-desktop-linux-x86_64.rpm" },
{ key: "linux-aarch64-deb", asset: "opencode-desktop-linux-arm64.deb" },
{ key: "linux-aarch64-rpm", asset: "opencode-desktop-linux-aarch64.rpm" },
{ key: "windows-aarch64-nsis", asset: "opencode-desktop-windows-arm64.exe" },
{ key: "windows-x86_64-nsis", asset: "opencode-desktop-windows-x64.exe" },
{ key: "darwin-x86_64-app", asset: "opencode-desktop-darwin-x64.app.tar.gz" },
{
key: "darwin-aarch64-app",
asset: "opencode-desktop-darwin-aarch64.app.tar.gz",
},
]
for (const target of targets) {
const asset = assetByName.get(target.asset)
if (!asset) continue
const sig = assetByName.get(`${target.asset}.sig`)
if (!sig) continue
const signature = await fetchSignature(sig)
add(target.key, asset, signature)
}
const winx = await read("latest-yml-x86_64-pc-windows-msvc", "latest.yml")
const wina = await read("latest-yml-aarch64-pc-windows-msvc", "latest.yml")
const macx = await read("latest-yml-x86_64-apple-darwin", "latest-mac.yml")
const maca = await read("latest-yml-aarch64-apple-darwin", "latest-mac.yml")
const linx = await read("latest-yml-x86_64-unknown-linux-gnu", "latest-linux.yml")
const lina = await read("latest-yml-aarch64-unknown-linux-gnu", "latest-linux-arm64.yml")
const alias = (key: string, source: string) => {
if (entries[key]) return
const entry = entries[source]
if (!entry) return
entries[key] = entry
}
const yver = winx?.version ?? wina?.version ?? macx?.version ?? maca?.version ?? linx?.version ?? lina?.version
if (yver && yver !== version) throw new Error(`latest.yml version mismatch: expected ${version}, got ${yver}`)
const out: Record<string, { url: string; signature: string }> = {}
const winxexe = pick(winx?.files ?? [], [".exe"])
const winaexe = pick(wina?.files ?? [], [".exe"])
const macxTarGz = "opencode-desktop-mac-x64.app.tar.gz"
const macaTarGz = "opencode-desktop-mac-arm64.app.tar.gz"
const linxDeb = pick(linx?.files ?? [], [".deb"])
const linxRpm = pick(linx?.files ?? [], [".rpm"])
const linxAppImage = pick(linx?.files ?? [], [".appimage"])
const linaDeb = pick(lina?.files ?? [], [".deb"])
const linaRpm = pick(lina?.files ?? [], [".rpm"])
const linaAppImage = pick(lina?.files ?? [], [".appimage"])
await add(out, "windows-x86_64-nsis", winxexe)
await add(out, "windows-aarch64-nsis", winaexe)
await add(out, "darwin-x86_64-app", macxTarGz)
await add(out, "darwin-aarch64-app", macaTarGz)
await add(out, "linux-x86_64-deb", linxDeb)
await add(out, "linux-x86_64-rpm", linxRpm)
await add(out, "linux-x86_64-appimage", linxAppImage)
await add(out, "linux-aarch64-deb", linaDeb)
await add(out, "linux-aarch64-rpm", linaRpm)
await add(out, "linux-aarch64-appimage", linaAppImage)
alias(out, "windows-x86_64", "windows-x86_64-nsis")
alias(out, "windows-aarch64", "windows-aarch64-nsis")
alias(out, "darwin-x86_64", "darwin-x86_64-app")
alias(out, "darwin-aarch64", "darwin-aarch64-app")
alias(out, "linux-x86_64", "linux-x86_64-deb")
alias(out, "linux-aarch64", "linux-aarch64-deb")
alias("linux-x86_64", "linux-x86_64-deb")
alias("linux-aarch64", "linux-aarch64-deb")
alias("windows-aarch64", "windows-aarch64-nsis")
alias("windows-x86_64", "windows-x86_64-nsis")
alias("darwin-x86_64", "darwin-x86_64-app")
alias("darwin-aarch64", "darwin-aarch64-app")
const platforms = Object.fromEntries(
Object.keys(out)
Object.keys(entries)
.sort()
.map((key) => [key, out[key]]),
.map((key) => [key, entries[key]]),
)
if (!Object.keys(platforms).length) throw new Error("No updater files found in latest.yml artifacts")
const data = {
version,
notes: "",
pub_date: new Date().toISOString(),
const output = {
...base,
platforms,
}
const tmp = process.env.RUNNER_TEMP ?? "/tmp"
const file = path.join(tmp, "latest.json")
await Bun.write(file, JSON.stringify(data, null, 2))
const dir = process.env.RUNNER_TEMP ?? "/tmp"
const file = `${dir}/latest.json`
await Bun.write(file, JSON.stringify(output, null, 2))
const tag = `v${version}`
const tag = release.tag_name
if (!tag) throw new Error("Release tag not found")
if (dryRun) {
console.log(`dry-run: wrote latest.json for ${tag} to ${file}`)

View File

@@ -71,11 +71,16 @@ const createPlatform = (): Platform => {
})()
const wslHome = async () => {
return undefined
if (os !== "windows" || !window.__OPENCODE__?.wsl) return undefined
return commands.wslPath("~", "windows").catch(() => undefined)
}
const handleWslPicker = async <T extends string | string[]>(result: T | null): Promise<T | null> => {
return result
if (!result || !window.__OPENCODE__?.wsl) return result
if (Array.isArray(result)) {
return Promise.all(result.map((path) => commands.wslPath(path, "linux").catch(() => path))) as any
}
return commands.wslPath(result, "linux").catch(() => result) as any
}
return {
@@ -344,6 +349,16 @@ const createPlatform = (): Platform => {
}
},
getWslEnabled: async () => {
const next = await commands.getWslConfig().catch(() => null)
if (next) return next.enabled
return window.__OPENCODE__!.wsl ?? false
},
setWslEnabled: async (enabled) => {
await commands.setWslConfig({ enabled })
},
getDefaultServer: async () => {
const url = await commands.getDefaultServerUrl().catch(() => null)
if (!url) return null

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.14.33",
"version": "1.14.32",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.14.33"
version = "1.14.32"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.14.33",
"version": "1.14.32",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,17 +0,0 @@
CREATE TABLE `session_message` (
`id` text PRIMARY KEY,
`session_id` text NOT NULL,
`type` text NOT NULL,
`time_created` integer NOT NULL,
`time_updated` integer NOT NULL,
`data` text NOT NULL,
CONSTRAINT `fk_session_message_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
DROP INDEX IF EXISTS `session_entry_session_idx`;--> statement-breakpoint
DROP INDEX IF EXISTS `session_entry_session_type_idx`;--> statement-breakpoint
DROP INDEX IF EXISTS `session_entry_time_created_idx`;--> statement-breakpoint
CREATE INDEX `session_message_session_idx` ON `session_message` (`session_id`);--> statement-breakpoint
CREATE INDEX `session_message_session_type_idx` ON `session_message` (`session_id`,`type`);--> statement-breakpoint
CREATE INDEX `session_message_time_created_idx` ON `session_message` (`time_created`);--> statement-breakpoint
DROP TABLE `session_entry`;

View File

@@ -2,7 +2,7 @@
"version": "7",
"dialect": "sqlite",
"id": "aaa2ebeb-caa4-478d-8365-4fc595d16856",
"prevIds": ["61f807f9-6398-4067-be05-804acc2561bc"],
"prevIds": ["66cbe0d7-def0-451b-b88a-7608513a9b44"],
"ddl": [
{
"name": "account_state",
@@ -37,7 +37,7 @@
"entityType": "tables"
},
{
"name": "session_message",
"name": "session_entry",
"entityType": "tables"
},
{
@@ -598,7 +598,7 @@
"generated": null,
"name": "id",
"entityType": "columns",
"table": "session_message"
"table": "session_entry"
},
{
"type": "text",
@@ -608,7 +608,7 @@
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "session_message"
"table": "session_entry"
},
{
"type": "text",
@@ -618,7 +618,7 @@
"generated": null,
"name": "type",
"entityType": "columns",
"table": "session_message"
"table": "session_entry"
},
{
"type": "integer",
@@ -628,7 +628,7 @@
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "session_message"
"table": "session_entry"
},
{
"type": "integer",
@@ -638,7 +638,7 @@
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "session_message"
"table": "session_entry"
},
{
"type": "text",
@@ -648,7 +648,7 @@
"generated": null,
"name": "data",
"entityType": "columns",
"table": "session_message"
"table": "session_entry"
},
{
"type": "text",
@@ -1112,9 +1112,9 @@
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_message_session_id_session_id_fk",
"name": "fk_session_entry_session_id_session_id_fk",
"entityType": "fks",
"table": "session_message"
"table": "session_entry"
},
{
"columns": ["project_id"],
@@ -1226,8 +1226,8 @@
{
"columns": ["id"],
"nameExplicit": false,
"name": "session_message_pk",
"table": "session_message",
"name": "session_entry_pk",
"table": "session_entry",
"entityType": "pks"
},
{
@@ -1322,9 +1322,9 @@
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_message_session_idx",
"name": "session_entry_session_idx",
"entityType": "indexes",
"table": "session_message"
"table": "session_entry"
},
{
"columns": [
@@ -1340,9 +1340,9 @@
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_message_session_type_idx",
"name": "session_entry_session_type_idx",
"entityType": "indexes",
"table": "session_message"
"table": "session_entry"
},
{
"columns": [
@@ -1354,9 +1354,9 @@
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_message_time_created_idx",
"name": "session_entry_time_created_idx",
"entityType": "indexes",
"table": "session_message"
"table": "session_entry"
},
{
"columns": [

View File

@@ -1,2 +0,0 @@
ALTER TABLE `session` ADD `agent` text;--> statement-breakpoint
ALTER TABLE `session` ADD `model` text;

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.33",
"version": "1.14.32",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -37,11 +37,6 @@
"bun": "./src/server/adapter.bun.ts",
"node": "./src/server/adapter.node.ts",
"default": "./src/server/adapter.bun.ts"
},
"#httpapi-server": {
"bun": "./src/server/httpapi-server.node.ts",
"node": "./src/server/httpapi-server.node.ts",
"default": "./src/server/httpapi-server.node.ts"
}
},
"devDependencies": {

View File

@@ -61,7 +61,6 @@ const createEmbeddedWebUIBundle = async () => {
await $`bun run --cwd ${appDir} build`
const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: dist })))
.map((file) => file.replaceAll("\\", "/"))
.filter((file) => !file.endsWith(".map"))
.sort()
const imports = files.map((file, i) => {
const spec = path.relative(dir, path.join(dist, file)).replaceAll("\\", "/")

File diff suppressed because it is too large Load Diff

View File

@@ -51,7 +51,6 @@ import { LoadAPIKeyError } from "ai"
import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
import { applyPatch } from "diff"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { ShellID } from "@/tool/shell/id"
type ModeOption = { id: string; name: string; description?: string }
type ModelOption = { modelId: string; name: string }
@@ -130,7 +129,7 @@ async function sendUsageUpdate(
})
}
export function init({ sdk: _sdk }: { sdk: OpencodeClient }) {
export async function init({ sdk: _sdk }: { sdk: OpencodeClient }) {
return {
create: (connection: AgentSideConnection, fullConfig: ACPConfig) => {
return new Agent(connection, fullConfig)
@@ -145,7 +144,7 @@ export class Agent implements ACPAgent {
private sessionManager: ACPSessionManager
private eventAbort = new AbortController()
private eventStarted = false
private shellSnapshots = new Map<string, string>()
private bashSnapshots = new Map<string, string>()
private toolStarts = new Set<string>()
private permissionQueues = new Map<string, Promise<void>>()
private permissionOptions: PermissionOption[] = [
@@ -284,16 +283,16 @@ export class Agent implements ACPAgent {
switch (part.state.status) {
case "pending":
this.shellSnapshots.delete(part.callID)
this.bashSnapshots.delete(part.callID)
return
case "running":
const output = this.shellOutput(part)
const output = this.bashOutput(part)
const content: ToolCallContent[] = []
if (output) {
const hash = Hash.fast(output)
if (part.tool === ShellID.ToolID) {
if (this.shellSnapshots.get(part.callID) === hash) {
if (part.tool === "bash") {
if (this.bashSnapshots.get(part.callID) === hash) {
await this.connection
.sessionUpdate({
sessionId,
@@ -312,7 +311,7 @@ export class Agent implements ACPAgent {
})
return
}
this.shellSnapshots.set(part.callID, hash)
this.bashSnapshots.set(part.callID, hash)
}
content.push({
type: "content",
@@ -343,7 +342,7 @@ export class Agent implements ACPAgent {
case "completed": {
this.toolStarts.delete(part.callID)
this.shellSnapshots.delete(part.callID)
this.bashSnapshots.delete(part.callID)
const kind = toToolKind(part.tool)
const content: ToolCallContent[] = [
{
@@ -424,7 +423,7 @@ export class Agent implements ACPAgent {
}
case "error":
this.toolStarts.delete(part.callID)
this.shellSnapshots.delete(part.callID)
this.bashSnapshots.delete(part.callID)
await this.connection
.sessionUpdate({
sessionId,
@@ -838,10 +837,10 @@ export class Agent implements ACPAgent {
await this.toolStart(sessionId, part)
switch (part.state.status) {
case "pending":
this.shellSnapshots.delete(part.callID)
this.bashSnapshots.delete(part.callID)
break
case "running":
const output = this.shellOutput(part)
const output = this.bashOutput(part)
const runningContent: ToolCallContent[] = []
if (output) {
runningContent.push({
@@ -872,7 +871,7 @@ export class Agent implements ACPAgent {
break
case "completed":
this.toolStarts.delete(part.callID)
this.shellSnapshots.delete(part.callID)
this.bashSnapshots.delete(part.callID)
const kind = toToolKind(part.tool)
const content: ToolCallContent[] = [
{
@@ -952,7 +951,7 @@ export class Agent implements ACPAgent {
break
case "error":
this.toolStarts.delete(part.callID)
this.shellSnapshots.delete(part.callID)
this.bashSnapshots.delete(part.callID)
await this.connection
.sessionUpdate({
sessionId,
@@ -1106,8 +1105,8 @@ export class Agent implements ACPAgent {
}
}
private shellOutput(part: ToolPart) {
if (part.tool !== ShellID.ToolID) return
private bashOutput(part: ToolPart) {
if (part.tool !== "bash") return
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return
const output = part.state.metadata["output"]
if (typeof output !== "string") return
@@ -1550,11 +1549,9 @@ export class Agent implements ACPAgent {
function toToolKind(toolName: string): ToolKind {
const tool = toolName.toLocaleLowerCase()
switch (tool) {
case ShellID.ToolID:
case "bash":
return "execute"
case "webfetch":
return "fetch"
@@ -1579,7 +1576,6 @@ function toToolKind(toolName: string): ToolKind {
function toLocations(toolName: string, input: Record<string, any>): { path: string }[] {
const tool = toolName.toLocaleLowerCase()
switch (tool) {
case "read":
case "edit":
@@ -1588,7 +1584,7 @@ function toLocations(toolName: string, input: Record<string, any>): { path: stri
case "glob":
case "grep":
return input["path"] ? [{ path: input["path"] }] : []
case ShellID.ToolID:
case "bash":
return []
default:
return []

View File

@@ -24,7 +24,6 @@ export function payloads() {
.map(([type, def]) => {
return z
.object({
id: z.string(),
type: z.literal(type),
properties: zodObject(def.properties),
})
@@ -40,7 +39,6 @@ export function effectPayloads() {
.entries()
.map(([type, def]) =>
Schema.Struct({
id: Schema.String,
type: Schema.Literal(type),
properties: def.properties,
}).annotate({ identifier: `Event.${type}` }),

View File

@@ -1,5 +1,4 @@
import { EventEmitter } from "events"
import { Identifier } from "@/id/id"
export type GlobalEvent = {
directory?: string
@@ -8,15 +7,6 @@ export type GlobalEvent = {
payload: any
}
class GlobalBusEmitter extends EventEmitter<{
export const GlobalBus = new EventEmitter<{
event: [GlobalEvent]
}> {
override emit(eventName: "event", event: GlobalEvent): boolean {
if (event.payload && typeof event.payload === "object" && !("id" in event.payload)) {
event.payload.id = event.payload.syncEvent?.id ?? Identifier.create("evt", "ascending")
}
return super.emit(eventName, event)
}
}
export const GlobalBus = new GlobalBusEmitter()
}>()

View File

@@ -5,7 +5,6 @@ import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Identifier } from "@/id/id"
const log = Log.create({ service: "bus" })
@@ -19,7 +18,6 @@ export const InstanceDisposed = BusEvent.define(
)
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
id: string
type: D["type"]
properties: BusProperties<D>
}
@@ -30,11 +28,7 @@ type State = {
}
export interface Interface {
readonly publish: <D extends BusEvent.Definition>(
def: D,
properties: BusProperties<D>,
options?: { id?: string },
) => Effect.Effect<void>
readonly publish: <D extends BusEvent.Definition>(def: D, properties: BusProperties<D>) => Effect.Effect<void>
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
readonly subscribeAll: () => Stream.Stream<Payload>
readonly subscribeCallback: <D extends BusEvent.Definition>(
@@ -59,7 +53,6 @@ export const layer = Layer.effect(
// Publish InstanceDisposed before shutting down so subscribers see it
yield* PubSub.publish(wildcard, {
type: InstanceDisposed.type,
id: createID(),
properties: { directory: ctx.directory },
})
yield* PubSub.shutdown(wildcard)
@@ -84,10 +77,10 @@ export const layer = Layer.effect(
})
}
function publish<D extends BusEvent.Definition>(def: D, properties: BusProperties<D>, options?: { id?: string }) {
function publish<D extends BusEvent.Definition>(def: D, properties: BusProperties<D>) {
return Effect.gen(function* () {
const s = yield* InstanceState.get(state)
const payload: Payload = { id: options?.id ?? createID(), type: def.type, properties }
const payload: Payload = { type: def.type, properties }
log.info("publishing", { type: def.type })
const ps = s.typed.get(def.type)
@@ -180,16 +173,8 @@ const { runPromise, runSync } = makeRuntime(Service, layer)
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
export function createID() {
return Identifier.create("evt", "ascending")
}
export async function publish<D extends BusEvent.Definition>(
def: D,
properties: BusProperties<D>,
options?: { id?: string },
) {
return runPromise((svc) => svc.publish(def, properties, options))
export async function publish<D extends BusEvent.Definition>(def: D, properties: BusProperties<D>) {
return runPromise((svc) => svc.publish(def, properties))
}
export function subscribe<D extends BusEvent.Definition>(def: D, callback: (event: Payload<D>) => unknown) {

View File

@@ -1,16 +1,15 @@
import { Instance } from "../project/instance"
import { InstanceRuntime } from "../project/instance-runtime"
import { WithInstance } from "../project/with-instance"
import { InstanceStore } from "../project/instance-store"
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
return WithInstance.provide({
return Instance.provide({
directory,
fn: async () => {
try {
const result = await cb()
return result
} finally {
await InstanceRuntime.disposeInstance(Instance.current)
await InstanceStore.runtime.runPromise((s) => s.dispose(Instance.current))
}
},
})

View File

@@ -3,7 +3,7 @@ import { Duration, Effect, Match, Option } from "effect"
import { UI } from "../ui"
import { Account } from "@/account/account"
import { AccountID, OrgID, PollExpired, type PollResult, type AccountError } from "@/account/schema"
import { effectCmd } from "../effect-cmd"
import { AppRuntime } from "@/effect/app-runtime"
import * as Prompt from "../effect/prompt"
import open from "open"
@@ -172,65 +172,60 @@ const openEffect = Effect.fn("open")(function* () {
yield* Prompt.outro("Opened " + url)
})
export const LoginCommand = effectCmd({
export const LoginCommand = cmd({
command: "login <url>",
describe: false,
instance: false,
builder: (yargs) =>
yargs.positional("url", {
describe: "server URL",
type: "string",
demandOption: true,
}),
handler: Effect.fn("Cli.account.login")(function* (args) {
async handler(args) {
UI.empty()
yield* Effect.orDie(loginEffect(args.url))
}),
await AppRuntime.runPromise(loginEffect(args.url))
},
})
export const LogoutCommand = effectCmd({
export const LogoutCommand = cmd({
command: "logout [email]",
describe: false,
instance: false,
builder: (yargs) =>
yargs.positional("email", {
describe: "account email to log out from",
type: "string",
}),
handler: Effect.fn("Cli.account.logout")(function* (args) {
async handler(args) {
UI.empty()
yield* Effect.orDie(logoutEffect(args.email))
}),
await AppRuntime.runPromise(logoutEffect(args.email))
},
})
export const SwitchCommand = effectCmd({
export const SwitchCommand = cmd({
command: "switch",
describe: false,
instance: false,
handler: Effect.fn("Cli.account.switch")(function* () {
async handler() {
UI.empty()
yield* Effect.orDie(switchEffect())
}),
await AppRuntime.runPromise(switchEffect())
},
})
export const OrgsCommand = effectCmd({
export const OrgsCommand = cmd({
command: "orgs",
describe: false,
instance: false,
handler: Effect.fn("Cli.account.orgs")(function* () {
async handler() {
UI.empty()
yield* Effect.orDie(orgsEffect())
}),
await AppRuntime.runPromise(orgsEffect())
},
})
export const OpenCommand = effectCmd({
export const OpenCommand = cmd({
command: "open",
describe: false,
instance: false,
handler: Effect.fn("Cli.account.open")(function* () {
async handler() {
UI.empty()
yield* Effect.orDie(openEffect())
}),
await AppRuntime.runPromise(openEffect())
},
})
export const ConsoleCommand = cmd({

View File

@@ -1,16 +1,15 @@
import * as Log from "@opencode-ai/core/util/log"
import { Effect } from "effect"
import { effectCmd } from "../effect-cmd"
import { bootstrap } from "../bootstrap"
import { cmd } from "./cmd"
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
import { ACP } from "@/acp/agent"
import { Server } from "@/server/server"
import { ServerAuth } from "@/server/auth"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
const log = Log.create({ service: "acp-command" })
export const AcpCommand = effectCmd({
export const AcpCommand = cmd({
command: "acp",
describe: "start ACP (Agent Client Protocol) server",
builder: (yargs) => {
@@ -20,54 +19,52 @@ export const AcpCommand = effectCmd({
default: process.cwd(),
})
},
handler: Effect.fn("Cli.acp")(function* (args) {
handler: async (args) => {
process.env.OPENCODE_CLIENT = "acp"
const opts = yield* Effect.promise(() => resolveNetworkOptions(args))
const server = yield* Effect.promise(() => Server.listen(opts))
await bootstrap(process.cwd(), async () => {
const opts = await resolveNetworkOptions(args)
const server = await Server.listen(opts)
const sdk = createOpencodeClient({
baseUrl: `http://${server.hostname}:${server.port}`,
headers: ServerAuth.headers(),
})
const sdk = createOpencodeClient({
baseUrl: `http://${server.hostname}:${server.port}`,
})
const input = new WritableStream<Uint8Array>({
write(chunk) {
return new Promise<void>((resolve, reject) => {
process.stdout.write(chunk, (err) => {
if (err) {
reject(err)
} else {
resolve()
}
const input = new WritableStream<Uint8Array>({
write(chunk) {
return new Promise<void>((resolve, reject) => {
process.stdout.write(chunk, (err) => {
if (err) {
reject(err)
} else {
resolve()
}
})
})
})
},
},
})
const output = new ReadableStream<Uint8Array>({
start(controller) {
process.stdin.on("data", (chunk: Buffer) => {
controller.enqueue(new Uint8Array(chunk))
})
process.stdin.on("end", () => controller.close())
process.stdin.on("error", (err) => controller.error(err))
},
})
const stream = ndJsonStream(input, output)
const agent = await ACP.init({ sdk })
new AgentSideConnection((conn) => {
return agent.create(conn, { sdk })
}, stream)
log.info("setup connection")
process.stdin.resume()
await new Promise((resolve, reject) => {
process.stdin.on("end", resolve)
process.stdin.on("error", reject)
})
})
const output = new ReadableStream<Uint8Array>({
start(controller) {
process.stdin.on("data", (chunk: Buffer) => {
controller.enqueue(new Uint8Array(chunk))
})
process.stdin.on("end", () => controller.close())
process.stdin.on("error", (err) => controller.error(err))
},
})
const stream = ndJsonStream(input, output)
const agent = ACP.init({ sdk })
new AgentSideConnection((conn) => {
return agent.create(conn, { sdk })
}, stream)
log.info("setup connection")
process.stdin.resume()
yield* Effect.promise(
() =>
new Promise<void>((resolve, reject) => {
process.stdin.on("end", () => resolve())
process.stdin.on("error", reject)
}),
)
}),
},
})

View File

@@ -1,5 +1,6 @@
import { cmd } from "./cmd"
import * as prompts from "@clack/prompts"
import { AppRuntime } from "@/effect/app-runtime"
import { UI } from "../ui"
import { Global } from "@opencode-ai/core/global"
import { Agent } from "../../agent/agent"
@@ -8,11 +9,9 @@ import path from "path"
import fs from "fs/promises"
import { Filesystem } from "@/util/filesystem"
import matter from "gray-matter"
import { InstanceRef } from "@/effect/instance-ref"
import { Instance } from "../../project/instance"
import { EOL } from "os"
import type { Argv } from "yargs"
import { Effect } from "effect"
import { effectCmd } from "../effect-cmd"
type AgentMode = "all" | "primary" | "subagent"
@@ -33,7 +32,7 @@ const AVAILABLE_PERMISSIONS = [
"skill",
]
const AgentCreateCommand = effectCmd({
const AgentCreateCommand = cmd({
command: "create",
describe: "create a new agent",
builder: (yargs: Argv) =>
@@ -61,191 +60,200 @@ const AgentCreateCommand = effectCmd({
alias: ["m"],
describe: "model to use in the format of provider/model",
}),
handler: Effect.fn("Cli.agent.create")(function* (args) {
const maybeCtx = yield* InstanceRef
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
const ctx = maybeCtx
const agentSvc = yield* Agent.Service
yield* Effect.promise(async () => {
const cliPath = args.path
const cliDescription = args.description
const cliMode = args.mode as AgentMode | undefined
const perms = args.permissions
async handler(args) {
await Instance.provide({
directory: process.cwd(),
async fn() {
const cliPath = args.path
const cliDescription = args.description
const cliMode = args.mode as AgentMode | undefined
const perms = args.permissions
const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined
const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined
if (!isFullyNonInteractive) {
UI.empty()
prompts.intro("Create agent")
}
if (!isFullyNonInteractive) {
UI.empty()
prompts.intro("Create agent")
}
const project = ctx.project
const project = Instance.project
// Determine scope/path
let targetPath: string
if (cliPath) {
targetPath = path.join(cliPath, "agent")
} else {
let scope: "global" | "project" = "global"
if (project.vcs === "git") {
const scopeResult = await prompts.select({
message: "Location",
// Determine scope/path
let targetPath: string
if (cliPath) {
targetPath = path.join(cliPath, "agent")
} else {
let scope: "global" | "project" = "global"
if (project.vcs === "git") {
const scopeResult = await prompts.select({
message: "Location",
options: [
{
label: "Current project",
value: "project" as const,
hint: Instance.worktree,
},
{
label: "Global",
value: "global" as const,
hint: Global.Path.config,
},
],
})
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
scope = scopeResult
}
targetPath = path.join(
scope === "global" ? Global.Path.config : path.join(Instance.worktree, ".opencode"),
"agent",
)
}
// Get description
let description: string
if (cliDescription) {
description = cliDescription
} else {
const query = await prompts.text({
message: "Description",
placeholder: "What should this agent do?",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(query)) throw new UI.CancelledError()
description = query
}
// Generate agent
const spinner = prompts.spinner()
spinner.start("Generating agent configuration...")
const model = args.model ? Provider.parseModel(args.model) : undefined
const generated = await AppRuntime.runPromise(
Agent.Service.use((svc) => svc.generate({ description, model })),
).catch((error) => {
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
if (isFullyNonInteractive) process.exit(1)
throw new UI.CancelledError()
})
spinner.stop(`Agent ${generated.identifier} generated`)
// Select permissions to allow
let selected: string[]
if (perms !== undefined) {
selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS
} else {
const result = await prompts.multiselect({
message: "Select permissions to allow (Space to toggle)",
options: AVAILABLE_PERMISSIONS.map((permission) => ({
label: permission,
value: permission,
})),
initialValues: AVAILABLE_PERMISSIONS,
})
if (prompts.isCancel(result)) throw new UI.CancelledError()
selected = result
}
// Get mode
let mode: AgentMode
if (cliMode) {
mode = cliMode
} else {
const modeResult = await prompts.select({
message: "Agent mode",
options: [
{
label: "Current project",
value: "project" as const,
hint: ctx.worktree,
label: "All",
value: "all" as const,
hint: "Can function in both primary and subagent roles",
},
{
label: "Global",
value: "global" as const,
hint: Global.Path.config,
label: "Primary",
value: "primary" as const,
hint: "Acts as a primary/main agent",
},
{
label: "Subagent",
value: "subagent" as const,
hint: "Can be used as a subagent by other agents",
},
],
initialValue: "all" as const,
})
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
scope = scopeResult
if (prompts.isCancel(modeResult)) throw new UI.CancelledError()
mode = modeResult
}
targetPath = path.join(scope === "global" ? Global.Path.config : path.join(ctx.worktree, ".opencode"), "agent")
}
// Get description
let description: string
if (cliDescription) {
description = cliDescription
} else {
const query = await prompts.text({
message: "Description",
placeholder: "What should this agent do?",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(query)) throw new UI.CancelledError()
description = query
}
// Generate agent
const spinner = prompts.spinner()
spinner.start("Generating agent configuration...")
const model = args.model ? Provider.parseModel(args.model) : undefined
const generated = await Effect.runPromise(agentSvc.generate({ description, model })).catch((error) => {
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
if (isFullyNonInteractive) process.exit(1)
throw new UI.CancelledError()
})
spinner.stop(`Agent ${generated.identifier} generated`)
// Select permissions to allow
let selected: string[]
if (perms !== undefined) {
selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS
} else {
const result = await prompts.multiselect({
message: "Select permissions to allow (Space to toggle)",
options: AVAILABLE_PERMISSIONS.map((permission) => ({
label: permission,
value: permission,
})),
initialValues: AVAILABLE_PERMISSIONS,
})
if (prompts.isCancel(result)) throw new UI.CancelledError()
selected = result
}
// Get mode
let mode: AgentMode
if (cliMode) {
mode = cliMode
} else {
const modeResult = await prompts.select({
message: "Agent mode",
options: [
{
label: "All",
value: "all" as const,
hint: "Can function in both primary and subagent roles",
},
{
label: "Primary",
value: "primary" as const,
hint: "Acts as a primary/main agent",
},
{
label: "Subagent",
value: "subagent" as const,
hint: "Can be used as a subagent by other agents",
},
],
initialValue: "all" as const,
})
if (prompts.isCancel(modeResult)) throw new UI.CancelledError()
mode = modeResult
}
// Build permissions config — deny anything not explicitly selected.
const permissions: Record<string, "deny"> = {}
for (const permission of AVAILABLE_PERMISSIONS) {
if (!selected.includes(permission)) {
permissions[permission] = "deny"
// Build permissions config — deny anything not explicitly selected.
const permissions: Record<string, "deny"> = {}
for (const permission of AVAILABLE_PERMISSIONS) {
if (!selected.includes(permission)) {
permissions[permission] = "deny"
}
}
}
// Build frontmatter
const frontmatter: {
description: string
mode: AgentMode
permission?: Record<string, "deny">
} = {
description: generated.whenToUse,
mode,
}
if (Object.keys(permissions).length > 0) {
frontmatter.permission = permissions
}
// Build frontmatter
const frontmatter: {
description: string
mode: AgentMode
permission?: Record<string, "deny">
} = {
description: generated.whenToUse,
mode,
}
if (Object.keys(permissions).length > 0) {
frontmatter.permission = permissions
}
// Write file
const content = matter.stringify(generated.systemPrompt, frontmatter)
const filePath = path.join(targetPath, `${generated.identifier}.md`)
// Write file
const content = matter.stringify(generated.systemPrompt, frontmatter)
const filePath = path.join(targetPath, `${generated.identifier}.md`)
await fs.mkdir(targetPath, { recursive: true })
await fs.mkdir(targetPath, { recursive: true })
if (await Filesystem.exists(filePath)) {
if (isFullyNonInteractive) {
console.error(`Error: Agent file already exists: ${filePath}`)
process.exit(1)
}
prompts.log.error(`Agent file already exists: ${filePath}`)
throw new UI.CancelledError()
}
await Filesystem.write(filePath, content)
if (await Filesystem.exists(filePath)) {
if (isFullyNonInteractive) {
console.error(`Error: Agent file already exists: ${filePath}`)
process.exit(1)
console.log(filePath)
} else {
prompts.log.success(`Agent created: ${filePath}`)
prompts.outro("Done")
}
prompts.log.error(`Agent file already exists: ${filePath}`)
throw new UI.CancelledError()
}
await Filesystem.write(filePath, content)
if (isFullyNonInteractive) {
console.log(filePath)
} else {
prompts.log.success(`Agent created: ${filePath}`)
prompts.outro("Done")
}
},
})
}),
},
})
const AgentListCommand = effectCmd({
const AgentListCommand = cmd({
command: "list",
describe: "list all available agents",
handler: Effect.fn("Cli.agent.list")(function* () {
const agents = yield* Agent.Service.use((svc) => svc.list())
const sortedAgents = agents.sort((a, b) => {
if (a.native !== b.native) {
return a.native ? -1 : 1
}
return a.name.localeCompare(b.name)
})
async handler() {
await Instance.provide({
directory: process.cwd(),
async fn() {
const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list()))
const sortedAgents = agents.sort((a, b) => {
if (a.native !== b.native) {
return a.native ? -1 : 1
}
return a.name.localeCompare(b.name)
})
for (const agent of sortedAgents) {
process.stdout.write(`${agent.name} (${agent.mode})` + EOL)
process.stdout.write(` ${JSON.stringify(agent.permission, null, 2)}` + EOL)
}
}),
for (const agent of sortedAgents) {
process.stdout.write(`${agent.name} (${agent.mode})` + EOL)
process.stdout.write(` ${JSON.stringify(agent.permission, null, 2)}` + EOL)
}
},
})
},
})
export const AgentCommand = cmd({

View File

@@ -1,6 +1,6 @@
import type { CommandModule } from "yargs"
export type WithDoubleDash<T> = T & { "--"?: string[] }
type WithDoubleDash<T> = T & { "--"?: string[] }
export function cmd<T, U>(input: CommandModule<T, WithDoubleDash<U>>) {
return input

View File

@@ -7,13 +7,14 @@ import { Session } from "@/session/session"
import type { MessageV2 } from "../../../session/message-v2"
import { MessageID, PartID } from "../../../session/schema"
import { ToolRegistry } from "@/tool/registry"
import { Instance } from "../../../project/instance"
import { Permission } from "../../../permission"
import { iife } from "../../../util/iife"
import { effectCmd, fail } from "../../effect-cmd"
import { InstanceRef } from "@/effect/instance-ref"
import type { InstanceContext } from "@/project/instance"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { AppRuntime } from "@/effect/app-runtime"
export const AgentCommand = effectCmd({
export const AgentCommand = cmd({
command: "agent <name>",
describe: "show agent configuration details",
builder: (yargs) =>
@@ -31,60 +32,60 @@ export const AgentCommand = effectCmd({
type: "string",
description: "Tool params as JSON or a JS object literal",
}),
handler: Effect.fn("Cli.debug.agent")(function* (args) {
const ctx = yield* InstanceRef
if (!ctx) return
return yield* run(args, ctx)
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
const agentName = args.name as string
const agent = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(agentName)))
if (!agent) {
process.stderr.write(
`Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL,
)
process.exit(1)
}
const availableTools = await getAvailableTools(agent)
const resolvedTools = await resolveTools(agent, availableTools)
const toolID = args.tool as string | undefined
if (toolID) {
const tool = availableTools.find((item) => item.id === toolID)
if (!tool) {
process.stderr.write(`Tool ${toolID} not found for agent ${agentName}` + EOL)
process.exit(1)
}
if (resolvedTools[toolID] === false) {
process.stderr.write(`Tool ${toolID} is disabled for agent ${agentName}` + EOL)
process.exit(1)
}
const params = parseToolParams(args.params as string | undefined)
const ctx = await createToolContext(agent)
const result = await tool.execute(params, ctx)
process.stdout.write(JSON.stringify({ tool: toolID, input: params, result }, null, 2) + EOL)
return
}
const output = {
...agent,
tools: resolvedTools,
}
process.stdout.write(JSON.stringify(output, null, 2) + EOL)
})
},
})
const run = Effect.fn("Cli.debug.agent.body")(function* (
args: { name: string; tool?: string; params?: string },
ctx: InstanceContext,
) {
const agentName = args.name
const agent = yield* Agent.Service.use((svc) => svc.get(agentName))
if (!agent) {
process.stderr.write(
`Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL,
)
return yield* fail("", 1)
}
const availableTools = yield* getAvailableTools(agent)
const resolvedTools = resolveTools(agent, availableTools)
const toolID = args.tool
if (toolID) {
const tool = availableTools.find((item) => item.id === toolID)
if (!tool) {
process.stderr.write(`Tool ${toolID} not found for agent ${agentName}` + EOL)
return yield* fail("", 1)
}
if (resolvedTools[toolID] === false) {
process.stderr.write(`Tool ${toolID} is disabled for agent ${agentName}` + EOL)
return yield* fail("", 1)
}
const params = parseToolParams(args.params)
const toolCtx = yield* createToolContext(agent, ctx)
const result = yield* tool.execute(params, toolCtx)
process.stdout.write(JSON.stringify({ tool: toolID, input: params, result }, null, 2) + EOL)
return
}
async function getAvailableTools(agent: Agent.Info) {
return AppRuntime.runPromise(
Effect.gen(function* () {
const provider = yield* Provider.Service
const registry = yield* ToolRegistry.Service
const model = agent.model ?? (yield* provider.defaultModel())
return yield* registry.tools({
...model,
agent,
})
}),
)
}
const output = {
...agent,
tools: resolvedTools,
}
process.stdout.write(JSON.stringify(output, null, 2) + EOL)
})
const getAvailableTools = Effect.fn("Cli.debug.agent.getAvailableTools")(function* (agent: Agent.Info) {
const provider = yield* Provider.Service
const registry = yield* ToolRegistry.Service
const model = agent.model ?? (yield* provider.defaultModel())
return yield* registry.tools({ ...model, agent })
})
function resolveTools(agent: Agent.Info, availableTools: { id: string }[]) {
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
const disabled = Permission.disabled(
availableTools.map((tool) => tool.id),
agent.permission,
@@ -122,38 +123,50 @@ function parseToolParams(input?: string) {
return parsed as Record<string, unknown>
}
const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(function* (
agent: Agent.Info,
ctx: InstanceContext,
) {
const sessionSvc = yield* Session.Service
const session = yield* sessionSvc.create({ title: `Debug tool run (${agent.name})` })
const messageID = MessageID.ascending()
const model = agent.model
? agent.model
: yield* Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* provider.defaultModel()
})
const now = Date.now()
const message: MessageV2.Assistant = {
id: messageID,
sessionID: session.id,
role: "assistant",
time: { created: now },
parentID: messageID,
modelID: model.modelID,
providerID: model.providerID,
mode: "debug",
agent: agent.name,
path: {
cwd: ctx.directory,
root: ctx.worktree,
},
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
}
yield* sessionSvc.updateMessage(message)
async function createToolContext(agent: Agent.Info) {
const { session, messageID } = await AppRuntime.runPromise(
Effect.gen(function* () {
const session = yield* Session.Service
const result = yield* session.create({ title: `Debug tool run (${agent.name})` })
const messageID = MessageID.ascending()
const model = agent.model
? agent.model
: yield* Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* provider.defaultModel()
})
const now = Date.now()
const message: MessageV2.Assistant = {
id: messageID,
sessionID: result.id,
role: "assistant",
time: {
created: now,
},
parentID: messageID,
modelID: model.modelID,
providerID: model.providerID,
mode: "debug",
agent: agent.name,
path: {
cwd: Instance.directory,
root: Instance.worktree,
},
cost: 0,
tokens: {
input: 0,
output: 0,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
}
yield* session.updateMessage(message)
return { session: result, messageID }
}),
)
const ruleset = Permission.merge(agent.permission, session.permission ?? [])
@@ -176,4 +189,4 @@ const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(functio
})
},
}
})
}

View File

@@ -1,14 +1,17 @@
import { EOL } from "os"
import { Effect } from "effect"
import { Config } from "@/config/config"
import { effectCmd } from "../../effect-cmd"
import { AppRuntime } from "@/effect/app-runtime"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
export const ConfigCommand = effectCmd({
export const ConfigCommand = cmd({
command: "config",
describe: "show resolved configuration",
builder: (yargs) => yargs,
handler: Effect.fn("Cli.debug.config")(function* () {
const config = yield* Config.Service.use((cfg) => cfg.get())
process.stdout.write(JSON.stringify(config, null, 2) + EOL)
}),
async handler() {
await bootstrap(process.cwd(), async () => {
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
process.stdout.write(JSON.stringify(config, null, 2) + EOL)
})
},
})

View File

@@ -1,11 +1,11 @@
import { EOL } from "os"
import { Effect } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { File } from "../../../file"
import { Ripgrep } from "@/file/ripgrep"
import { effectCmd } from "../../effect-cmd"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
const FileSearchCommand = effectCmd({
const FileSearchCommand = cmd({
command: "search <query>",
describe: "search files by query",
builder: (yargs) =>
@@ -14,13 +14,15 @@ const FileSearchCommand = effectCmd({
demandOption: true,
description: "Search query",
}),
handler: Effect.fn("Cli.debug.file.search")(function* (args) {
const results = yield* File.Service.use((svc) => svc.search({ query: args.query }))
process.stdout.write(results.join(EOL) + EOL)
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
const results = await AppRuntime.runPromise(File.Service.use((svc) => svc.search({ query: args.query })))
process.stdout.write(results.join(EOL) + EOL)
})
},
})
const FileReadCommand = effectCmd({
const FileReadCommand = cmd({
command: "read <path>",
describe: "read file contents as JSON",
builder: (yargs) =>
@@ -29,23 +31,27 @@ const FileReadCommand = effectCmd({
demandOption: true,
description: "File path to read",
}),
handler: Effect.fn("Cli.debug.file.read")(function* (args) {
const content = yield* File.Service.use((svc) => svc.read(args.path))
process.stdout.write(JSON.stringify(content, null, 2) + EOL)
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
const content = await AppRuntime.runPromise(File.Service.use((svc) => svc.read(args.path)))
process.stdout.write(JSON.stringify(content, null, 2) + EOL)
})
},
})
const FileStatusCommand = effectCmd({
const FileStatusCommand = cmd({
command: "status",
describe: "show file status information",
builder: (yargs) => yargs,
handler: Effect.fn("Cli.debug.file.status")(function* () {
const status = yield* File.Service.use((svc) => svc.status())
process.stdout.write(JSON.stringify(status, null, 2) + EOL)
}),
async handler() {
await bootstrap(process.cwd(), async () => {
const status = await AppRuntime.runPromise(File.Service.use((svc) => svc.status()))
process.stdout.write(JSON.stringify(status, null, 2) + EOL)
})
},
})
const FileListCommand = effectCmd({
const FileListCommand = cmd({
command: "list <path>",
describe: "list files in a directory",
builder: (yargs) =>
@@ -54,13 +60,15 @@ const FileListCommand = effectCmd({
demandOption: true,
description: "File path to list",
}),
handler: Effect.fn("Cli.debug.file.list")(function* (args) {
const files = yield* File.Service.use((svc) => svc.list(args.path))
process.stdout.write(JSON.stringify(files, null, 2) + EOL)
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
const files = await AppRuntime.runPromise(File.Service.use((svc) => svc.list(args.path)))
process.stdout.write(JSON.stringify(files, null, 2) + EOL)
})
},
})
const FileTreeCommand = effectCmd({
const FileTreeCommand = cmd({
command: "tree [dir]",
describe: "show directory tree",
builder: (yargs) =>
@@ -69,10 +77,12 @@ const FileTreeCommand = effectCmd({
description: "Directory to tree",
default: process.cwd(),
}),
handler: Effect.fn("Cli.debug.file.tree")(function* (args) {
const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 })))
console.log(JSON.stringify(tree, null, 2))
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
const tree = await AppRuntime.runPromise(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 })))
console.log(JSON.stringify(tree, null, 2))
})
},
})
export const FileCommand = cmd({

View File

@@ -1,11 +1,5 @@
import { Global } from "@opencode-ai/core/global"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Flag } from "@opencode-ai/core/flag/flag"
import os from "os"
import { Duration, Effect } from "effect"
import { Config } from "@/config/config"
import { ConfigPlugin } from "@/config/plugin"
import { effectCmd } from "../../effect-cmd"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { ConfigCommand } from "./config"
import { FileCommand } from "./file"
@@ -31,49 +25,20 @@ export const DebugCommand = cmd({
.command(SnapshotCommand)
.command(StartupCommand)
.command(AgentCommand)
.command(InfoCommand)
.command(PathsCommand)
.command(WaitCommand)
.command({
command: "wait",
describe: "wait indefinitely (for debugging)",
async handler() {
await bootstrap(process.cwd(), async () => {
await new Promise((resolve) => setTimeout(resolve, 1_000 * 60 * 60 * 24))
})
},
})
.demandCommand(),
async handler() {},
})
const WaitCommand = effectCmd({
command: "wait",
describe: "wait indefinitely (for debugging)",
handler: Effect.fn("Cli.debug.wait")(function* () {
yield* Effect.sleep(Duration.days(1))
}),
})
const InfoCommand = effectCmd({
command: "info",
describe: "show debug information",
handler: Effect.fn("Cli.debug.info")(function* () {
const config = yield* Config.Service.use((cfg) => cfg.get())
const termProgram = process.env.TERM_PROGRAM
? `${process.env.TERM_PROGRAM}${process.env.TERM_PROGRAM_VERSION ? ` ${process.env.TERM_PROGRAM_VERSION}` : ""}`
: undefined
const terminal = [termProgram, process.env.TERM].filter((item): item is string => Boolean(item)).join(" / ")
console.log(`opencode version: ${InstallationVersion}`)
console.log(`os: ${os.type()} ${os.release()} ${os.arch()}`)
console.log(`terminal: ${terminal || "unknown"}`)
console.log("plugins:")
if (Flag.OPENCODE_PURE) {
console.log("external plugins disabled (--pure)")
return
}
if (!config.plugin_origins?.length) {
console.log("none")
return
}
for (const plugin of config.plugin_origins) {
console.log(`- ${ConfigPlugin.pluginSpecifier(plugin.spec)}`)
}
}),
})
const PathsCommand = cmd({
command: "paths",
describe: "show global paths (data, config, cache, state)",

View File

@@ -1,6 +1,7 @@
import { LSP } from "@/lsp/lsp"
import { AppRuntime } from "../../../effect/app-runtime"
import { Effect } from "effect"
import { effectCmd } from "../../effect-cmd"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import * as Log from "@opencode-ai/core/util/log"
import { EOL } from "os"
@@ -13,39 +14,47 @@ export const LSPCommand = cmd({
async handler() {},
})
const DiagnosticsCommand = effectCmd({
const DiagnosticsCommand = cmd({
command: "diagnostics <file>",
describe: "get diagnostics for a file",
builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }),
handler: Effect.fn("Cli.debug.lsp.diagnostics")(function* (args) {
const out = yield* LSP.Service.use((lsp) =>
Effect.gen(function* () {
yield* lsp.touchFile(args.file, "full")
return yield* lsp.diagnostics()
}),
)
process.stdout.write(JSON.stringify(out, null, 2) + EOL)
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
const out = await AppRuntime.runPromise(
LSP.Service.use((lsp) =>
Effect.gen(function* () {
yield* lsp.touchFile(args.file, "full")
return yield* lsp.diagnostics()
}),
),
)
process.stdout.write(JSON.stringify(out, null, 2) + EOL)
})
},
})
export const SymbolsCommand = effectCmd({
export const SymbolsCommand = cmd({
command: "symbols <query>",
describe: "search workspace symbols",
builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }),
handler: Effect.fn("Cli.debug.lsp.symbols")(function* (args) {
using _ = Log.Default.time("symbols")
const results = yield* LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query))
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
using _ = Log.Default.time("symbols")
const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query)))
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
})
},
})
export const DocumentSymbolsCommand = effectCmd({
export const DocumentSymbolsCommand = cmd({
command: "document-symbols <uri>",
describe: "get symbols from a document",
builder: (yargs) => yargs.positional("uri", { type: "string", demandOption: true }),
handler: Effect.fn("Cli.debug.lsp.documentSymbols")(function* (args) {
using _ = Log.Default.time("document-symbols")
const results = yield* LSP.Service.use((lsp) => lsp.documentSymbol(args.uri))
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
using _ = Log.Default.time("document-symbols")
const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.documentSymbol(args.uri)))
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
})
},
})

View File

@@ -1,9 +1,10 @@
import { EOL } from "os"
import { Effect, Stream } from "effect"
import { AppRuntime } from "../../../effect/app-runtime"
import { Ripgrep } from "../../../file/ripgrep"
import { effectCmd } from "../../effect-cmd"
import { Instance } from "../../../project/instance"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { InstanceRef } from "@/effect/instance-ref"
export const RipgrepCommand = cmd({
command: "rg",
@@ -12,22 +13,24 @@ export const RipgrepCommand = cmd({
async handler() {},
})
const TreeCommand = effectCmd({
const TreeCommand = cmd({
command: "tree",
describe: "show file tree using ripgrep",
builder: (yargs) =>
yargs.option("limit", {
type: "number",
}),
handler: Effect.fn("Cli.debug.rg.tree")(function* (args) {
const ctx = yield* InstanceRef
if (!ctx) return
const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit })))
process.stdout.write(tree + EOL)
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
const tree = await AppRuntime.runPromise(
Ripgrep.Service.use((svc) => svc.tree({ cwd: Instance.directory, limit: args.limit })),
)
process.stdout.write(tree + EOL)
})
},
})
const FilesCommand = effectCmd({
const FilesCommand = cmd({
command: "files",
describe: "list files using ripgrep",
builder: (yargs) =>
@@ -44,26 +47,29 @@ const FilesCommand = effectCmd({
type: "number",
description: "Limit number of results",
}),
handler: Effect.fn("Cli.debug.rg.files")(function* (args) {
const ctx = yield* InstanceRef
if (!ctx) return
const rg = yield* Ripgrep.Service
const files = yield* rg
.files({
cwd: ctx.directory,
glob: args.glob ? [args.glob] : undefined,
})
.pipe(
Stream.take(args.limit ?? Infinity),
Stream.runCollect,
Effect.map((c) => [...c]),
Effect.orDie,
async handler(args) {
await bootstrap(process.cwd(), async () => {
const files = await AppRuntime.runPromise(
Effect.gen(function* () {
const rg = yield* Ripgrep.Service
return yield* rg
.files({
cwd: Instance.directory,
glob: args.glob ? [args.glob] : undefined,
})
.pipe(
Stream.take(args.limit ?? Infinity),
Stream.runCollect,
Effect.map((c) => [...c]),
)
}),
)
process.stdout.write(files.join(EOL) + EOL)
}),
process.stdout.write(files.join(EOL) + EOL)
})
},
})
const SearchCommand = effectCmd({
const SearchCommand = cmd({
command: "search <pattern>",
describe: "search file contents using ripgrep",
builder: (yargs) =>
@@ -81,19 +87,19 @@ const SearchCommand = effectCmd({
type: "number",
description: "Limit number of results",
}),
handler: Effect.fn("Cli.debug.rg.search")(function* (args) {
const ctx = yield* InstanceRef
if (!ctx) return
const results = yield* Effect.orDie(
Ripgrep.Service.use((svc) =>
svc.search({
cwd: ctx.directory,
pattern: args.pattern,
glob: args.glob as string[] | undefined,
limit: args.limit,
}),
),
)
process.stdout.write(JSON.stringify(results.items, null, 2) + EOL)
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
const results = await AppRuntime.runPromise(
Ripgrep.Service.use((svc) =>
svc.search({
cwd: Instance.directory,
pattern: args.pattern,
glob: args.glob as string[] | undefined,
limit: args.limit,
}),
),
)
process.stdout.write(JSON.stringify(results.items, null, 2) + EOL)
})
},
})

View File

@@ -1,15 +1,23 @@
import { EOL } from "os"
import { Effect } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { Skill } from "../../../skill"
import { effectCmd } from "../../effect-cmd"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
export const SkillCommand = effectCmd({
export const SkillCommand = cmd({
command: "skill",
describe: "list all available skills",
builder: (yargs) => yargs,
handler: Effect.fn("Cli.debug.skill")(function* () {
const skill = yield* Skill.Service
const skills = yield* skill.all()
process.stdout.write(JSON.stringify(skills, null, 2) + EOL)
}),
async handler() {
await bootstrap(process.cwd(), async () => {
const skills = await AppRuntime.runPromise(
Effect.gen(function* () {
const skill = yield* Skill.Service
return yield* skill.all()
}),
)
process.stdout.write(JSON.stringify(skills, null, 2) + EOL)
})
},
})

View File

@@ -1,6 +1,6 @@
import { Effect } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { Snapshot } from "../../../snapshot"
import { effectCmd } from "../../effect-cmd"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
export const SnapshotCommand = cmd({
@@ -10,16 +10,17 @@ export const SnapshotCommand = cmd({
async handler() {},
})
const TrackCommand = effectCmd({
const TrackCommand = cmd({
command: "track",
describe: "track current snapshot state",
handler: Effect.fn("Cli.debug.snapshot.track")(function* () {
const out = yield* Snapshot.Service.use((svc) => svc.track())
console.log(out)
}),
async handler() {
await bootstrap(process.cwd(), async () => {
console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.track())))
})
},
})
const PatchCommand = effectCmd({
const PatchCommand = cmd({
command: "patch <hash>",
describe: "show patch for a snapshot hash",
builder: (yargs) =>
@@ -28,13 +29,14 @@ const PatchCommand = effectCmd({
description: "hash",
demandOption: true,
}),
handler: Effect.fn("Cli.debug.snapshot.patch")(function* (args) {
const out = yield* Snapshot.Service.use((svc) => svc.patch(args.hash))
console.log(out)
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.patch(args.hash))))
})
},
})
const DiffCommand = effectCmd({
const DiffCommand = cmd({
command: "diff <hash>",
describe: "show diff for a snapshot hash",
builder: (yargs) =>
@@ -43,8 +45,9 @@ const DiffCommand = effectCmd({
description: "hash",
demandOption: true,
}),
handler: Effect.fn("Cli.debug.snapshot.diff")(function* (args) {
const out = yield* Snapshot.Service.use((svc) => svc.diff(args.hash))
console.log(out)
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.diff(args.hash))))
})
},
})

Some files were not shown because too many files have changed in this diff Show More