Compare commits

..

1 Commits

Author SHA1 Message Date
opencode
006a05abe8 release: v1.14.33 2026-05-02 19:52:55 +00:00
266 changed files with 19467 additions and 32839 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

@@ -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-SLWRe4uPSRWgU+NPa1BywmrUtNVIC0Oy2mjmxclxk+s=",
"aarch64-linux": "sha256-toHEeIqMzrmThoV0B52juGKm4pa/aJN3gBFFtrSZp2Q=",
"aarch64-darwin": "sha256-lYUsUxq5zR2RXjqZTEdjduOncnlwvTlxDJVKWXJuKPY=",
"x86_64-darwin": "sha256-77XmuEYqGwb1mkEHfnghq1VtukFTneohA0FW6WDOk1U="
}
}

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

@@ -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

@@ -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,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

@@ -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

@@ -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

@@ -97,7 +97,6 @@ export const layer = Layer.effect(
question: "deny",
plan_enter: "deny",
plan_exit: "deny",
edit: "ask",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",

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,17 @@
import { Instance } from "../project/instance"
import { InstanceRuntime } from "../project/instance-runtime"
import { WithInstance } from "../project/with-instance"
import { InstanceStore } from "../project/instance-store"
import { getBootstrapRunEffect } from "../effect/app-runtime"
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
return WithInstance.provide({
return Instance.provide({
directory,
init: await getBootstrapRunEffect(),
fn: async () => {
try {
const result = await cb()
return result
} finally {
await InstanceRuntime.disposeInstance(Instance.current)
await InstanceStore.disposeInstance(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))))
})
},
})

View File

@@ -1,11 +1,13 @@
import type { Argv } from "yargs"
import { Session } from "@/session/session"
import { MessageV2 } from "../../session/message-v2"
import { SessionID } from "../../session/schema"
import { effectCmd, fail } from "../effect-cmd"
import { cmd } from "./cmd"
import { bootstrap } from "../bootstrap"
import { UI } from "../ui"
import * as prompts from "@clack/prompts"
import { EOL } from "os"
import { Effect } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
function redact(kind: string, id: string, value: string) {
return value.trim() ? `[redacted:${kind}:${id}]` : value
@@ -218,11 +220,11 @@ function sanitize(data: { info: Session.Info; messages: MessageV2.WithParts[] })
}
}
export const ExportCommand = effectCmd({
export const ExportCommand = cmd({
command: "export [sessionID]",
describe: "export session data as JSON",
builder: (yargs) =>
yargs
builder: (yargs: Argv) => {
return yargs
.positional("sessionID", {
describe: "session id to export",
type: "string",
@@ -230,62 +232,72 @@ export const ExportCommand = effectCmd({
.option("sanitize", {
describe: "redact sensitive transcript and file data",
type: "boolean",
}),
handler: Effect.fn("Cli.export")(function* (args) {
return yield* run(args)
}),
})
const run = Effect.fn("Cli.export.body")(function* (args: { sessionID?: string; sanitize?: boolean }) {
const svc = yield* Session.Service
let sessionID = args.sessionID ? SessionID.make(args.sessionID) : undefined
process.stderr.write(`Exporting session: ${sessionID ?? "latest"}\n`)
if (!sessionID) {
UI.empty()
prompts.intro("Export session", { output: process.stderr })
const sessions = yield* svc.list()
if (sessions.length === 0) {
prompts.log.error("No sessions found", { output: process.stderr })
prompts.outro("Done", { output: process.stderr })
return
}
sessions.sort((a, b) => b.time.updated - a.time.updated)
const selectedSession = yield* Effect.promise(() =>
prompts.autocomplete({
message: "Select session to export",
maxItems: 10,
options: sessions.map((session) => ({
label: session.title,
value: session.id,
hint: `${new Date(session.time.updated).toLocaleString()}${session.id.slice(-8)}`,
})),
output: process.stderr,
}),
)
if (prompts.isCancel(selectedSession)) {
return yield* Effect.die(new UI.CancelledError())
}
sessionID = selectedSession
prompts.outro("Exporting session...", { output: process.stderr })
}
// Match legacy try/catch — catches both typed failures and defects
// (Session.Service.get throws NotFoundError as a defect, not a typed E).
return yield* Effect.gen(function* () {
const sessionInfo = yield* svc.get(sessionID!)
const messages = yield* svc.messages({ sessionID: sessionInfo.id })
const exportData = { info: sessionInfo, messages }
process.stdout.write(JSON.stringify(args.sanitize ? sanitize(exportData) : exportData, null, 2))
process.stdout.write(EOL)
}).pipe(Effect.catchCause(() => fail(`Session not found: ${sessionID!}`)))
})
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
let sessionID = args.sessionID ? SessionID.make(args.sessionID) : undefined
process.stderr.write(`Exporting session: ${sessionID ?? "latest"}\n`)
if (!sessionID) {
UI.empty()
prompts.intro("Export session", {
output: process.stderr,
})
const sessions = await AppRuntime.runPromise(Session.Service.use((svc) => svc.list()))
if (sessions.length === 0) {
prompts.log.error("No sessions found", {
output: process.stderr,
})
prompts.outro("Done", {
output: process.stderr,
})
return
}
sessions.sort((a, b) => b.time.updated - a.time.updated)
const selectedSession = await prompts.autocomplete({
message: "Select session to export",
maxItems: 10,
options: sessions.map((session) => ({
label: session.title,
value: session.id,
hint: `${new Date(session.time.updated).toLocaleString()}${session.id.slice(-8)}`,
})),
output: process.stderr,
})
if (prompts.isCancel(selectedSession)) {
throw new UI.CancelledError()
}
sessionID = selectedSession
prompts.outro("Exporting session...", {
output: process.stderr,
})
}
try {
const sessionInfo = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID!)))
const messages = await AppRuntime.runPromise(
Session.Service.use((svc) => svc.messages({ sessionID: sessionInfo.id })),
)
const exportData = {
info: sessionInfo,
messages,
}
process.stdout.write(JSON.stringify(args.sanitize ? sanitize(exportData) : exportData, null, 2))
process.stdout.write(EOL)
} catch {
UI.error(`Session not found: ${sessionID!}`)
process.exit(1)
}
})
},
})

View File

@@ -1,28 +1,22 @@
import { Server } from "../../server/server"
import { PublicApi } from "../../server/routes/instance/httpapi/public"
import type { CommandModule } from "yargs"
import { OpenApi } from "effect/unstable/httpapi"
type Args = {
httpapi: boolean
hono: boolean
}
export const GenerateCommand = {
command: "generate",
builder: (yargs) =>
yargs
.option("httpapi", {
type: "boolean",
default: false,
description:
"Generate OpenAPI from the Effect HttpApi contract (default; flag retained for backwards compatibility)",
})
.option("hono", {
type: "boolean",
default: false,
description: "Generate OpenAPI from the legacy Hono backend (parity-diff only; will be removed)",
}),
yargs.option("httpapi", {
type: "boolean",
default: false,
description: "Generate OpenAPI from the experimental Effect HttpApi contract",
}),
handler: async (args) => {
const specs = args.hono ? await Server.openapiHono() : await Server.openapi()
const specs = args.httpapi ? OpenApi.fromApi(PublicApi) : await Server.openapi()
for (const item of Object.values(specs.paths)) {
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
const operation = item[method]

View File

@@ -18,9 +18,9 @@ import type {
} from "@octokit/webhooks-types"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { effectCmd } from "../effect-cmd"
import { ModelsDev } from "@/provider/models"
import { InstanceRef } from "@/effect/instance-ref"
import { Instance } from "@/project/instance"
import { bootstrap } from "../bootstrap"
import { SessionShare } from "@/share/session"
import { Session } from "@/session/session"
import type { SessionID } from "../../session/schema"
@@ -199,192 +199,191 @@ export const GithubCommand = cmd({
async handler() {},
})
export const GithubInstallCommand = effectCmd({
export const GithubInstallCommand = cmd({
command: "install",
describe: "install the GitHub agent",
handler: Effect.fn("Cli.github.install")(function* () {
const maybeCtx = yield* InstanceRef
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
const ctx = maybeCtx
yield* Effect.promise(async () => {
{
UI.empty()
prompts.intro("Install GitHub agent")
const app = await getAppInfo()
await installGitHubApp()
async handler() {
await Instance.provide({
directory: process.cwd(),
async fn() {
{
UI.empty()
prompts.intro("Install GitHub agent")
const app = await getAppInfo()
await installGitHubApp()
const providers = await AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())).then((p) => {
// TODO: add guide for copilot, for now just hide it
delete p["github-copilot"]
return p
})
const provider = await promptProvider()
const model = await promptModel()
//const key = await promptKey()
await addWorkflowFiles()
printNextSteps()
function printNextSteps() {
let step2
if (provider === "amazon-bedrock") {
step2 =
"Configure OIDC in AWS - https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services"
} else {
step2 = [
` 2. Add the following secrets in org or repo (${app.owner}/${app.repo}) settings`,
"",
...providers[provider].env.map((e) => ` - ${e}`),
].join("\n")
}
prompts.outro(
[
"Next steps:",
"",
` 1. Commit the \`${WORKFLOW_FILE}\` file and push`,
step2,
"",
" 3. Go to a GitHub issue and comment `/oc summarize` to see the agent in action",
"",
" Learn more about the GitHub agent - https://opencode.ai/docs/github/#usage-examples",
].join("\n"),
)
}
async function getAppInfo() {
const project = ctx.project
if (project.vcs !== "git") {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
throw new UI.CancelledError()
}
// Get repo info
const info = await AppRuntime.runPromise(
Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: ctx.worktree })),
).then((x) => x.text().trim())
const parsed = parseGitHubRemote(info)
if (!parsed) {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
throw new UI.CancelledError()
}
return { owner: parsed.owner, repo: parsed.repo, root: ctx.worktree }
}
async function promptProvider() {
const priority: Record<string, number> = {
opencode: 0,
anthropic: 1,
openai: 2,
google: 3,
}
let provider = await prompts.select({
message: "Select provider",
maxItems: 8,
options: pipe(
providers,
values(),
sortBy(
(x) => priority[x.id] ?? 99,
(x) => x.name ?? x.id,
),
map((x) => ({
label: x.name,
value: x.id,
hint: priority[x.id] === 0 ? "recommended" : undefined,
})),
),
const providers = await AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())).then((p) => {
// TODO: add guide for copilot, for now just hide it
delete p["github-copilot"]
return p
})
if (prompts.isCancel(provider)) throw new UI.CancelledError()
const provider = await promptProvider()
const model = await promptModel()
//const key = await promptKey()
return provider
}
await addWorkflowFiles()
printNextSteps()
async function promptModel() {
const providerData = providers[provider]!
const model = await prompts.select({
message: "Select model",
maxItems: 8,
options: pipe(
providerData.models,
values(),
sortBy((x) => x.name ?? x.id),
map((x) => ({
label: x.name ?? x.id,
value: x.id,
})),
),
})
if (prompts.isCancel(model)) throw new UI.CancelledError()
return model
}
async function installGitHubApp() {
const s = prompts.spinner()
s.start("Installing GitHub app")
// Get installation
const installation = await getInstallation()
if (installation) return s.stop("GitHub app already installed")
// Open browser
const url = "https://github.com/apps/opencode-agent"
const command =
process.platform === "darwin"
? `open "${url}"`
: process.platform === "win32"
? `start "" "${url}"`
: `xdg-open "${url}"`
exec(command, (error) => {
if (error) {
prompts.log.warn(`Could not open browser. Please visit: ${url}`)
function printNextSteps() {
let step2
if (provider === "amazon-bedrock") {
step2 =
"Configure OIDC in AWS - https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services"
} else {
step2 = [
` 2. Add the following secrets in org or repo (${app.owner}/${app.repo}) settings`,
"",
...providers[provider].env.map((e) => ` - ${e}`),
].join("\n")
}
})
// Wait for installation
s.message("Waiting for GitHub app to be installed")
const MAX_RETRIES = 120
let retries = 0
do {
const installation = await getInstallation()
if (installation) break
prompts.outro(
[
"Next steps:",
"",
` 1. Commit the \`${WORKFLOW_FILE}\` file and push`,
step2,
"",
" 3. Go to a GitHub issue and comment `/oc summarize` to see the agent in action",
"",
" Learn more about the GitHub agent - https://opencode.ai/docs/github/#usage-examples",
].join("\n"),
)
}
if (retries > MAX_RETRIES) {
s.stop(
`Failed to detect GitHub app installation. Make sure to install the app for the \`${app.owner}/${app.repo}\` repository.`,
)
async function getAppInfo() {
const project = Instance.project
if (project.vcs !== "git") {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
throw new UI.CancelledError()
}
retries++
await sleep(1000)
} while (true) // oxlint-disable-line no-constant-condition
s.stop("Installed GitHub app")
async function getInstallation() {
return await fetch(
`https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`,
)
.then((res) => res.json())
.then((data) => data.installation)
// Get repo info
const info = await AppRuntime.runPromise(
Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })),
).then((x) => x.text().trim())
const parsed = parseGitHubRemote(info)
if (!parsed) {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
throw new UI.CancelledError()
}
return { owner: parsed.owner, repo: parsed.repo, root: Instance.worktree }
}
}
async function addWorkflowFiles() {
const envStr =
provider === "amazon-bedrock"
? ""
: `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}`
async function promptProvider() {
const priority: Record<string, number> = {
opencode: 0,
anthropic: 1,
openai: 2,
google: 3,
}
let provider = await prompts.select({
message: "Select provider",
maxItems: 8,
options: pipe(
providers,
values(),
sortBy(
(x) => priority[x.id] ?? 99,
(x) => x.name ?? x.id,
),
map((x) => ({
label: x.name,
value: x.id,
hint: priority[x.id] === 0 ? "recommended" : undefined,
})),
),
})
await Filesystem.write(
path.join(app.root, WORKFLOW_FILE),
`name: opencode
if (prompts.isCancel(provider)) throw new UI.CancelledError()
return provider
}
async function promptModel() {
const providerData = providers[provider]!
const model = await prompts.select({
message: "Select model",
maxItems: 8,
options: pipe(
providerData.models,
values(),
sortBy((x) => x.name ?? x.id),
map((x) => ({
label: x.name ?? x.id,
value: x.id,
})),
),
})
if (prompts.isCancel(model)) throw new UI.CancelledError()
return model
}
async function installGitHubApp() {
const s = prompts.spinner()
s.start("Installing GitHub app")
// Get installation
const installation = await getInstallation()
if (installation) return s.stop("GitHub app already installed")
// Open browser
const url = "https://github.com/apps/opencode-agent"
const command =
process.platform === "darwin"
? `open "${url}"`
: process.platform === "win32"
? `start "" "${url}"`
: `xdg-open "${url}"`
exec(command, (error) => {
if (error) {
prompts.log.warn(`Could not open browser. Please visit: ${url}`)
}
})
// Wait for installation
s.message("Waiting for GitHub app to be installed")
const MAX_RETRIES = 120
let retries = 0
do {
const installation = await getInstallation()
if (installation) break
if (retries > MAX_RETRIES) {
s.stop(
`Failed to detect GitHub app installation. Make sure to install the app for the \`${app.owner}/${app.repo}\` repository.`,
)
throw new UI.CancelledError()
}
retries++
await sleep(1000)
} while (true) // oxlint-disable-line no-constant-condition
s.stop("Installed GitHub app")
async function getInstallation() {
return await fetch(
`https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`,
)
.then((res) => res.json())
.then((data) => data.installation)
}
}
async function addWorkflowFiles() {
const envStr =
provider === "amazon-bedrock"
? ""
: `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}`
await Filesystem.write(
path.join(app.root, WORKFLOW_FILE),
`name: opencode
on:
issue_comment:
@@ -415,16 +414,17 @@ jobs:
uses: anomalyco/opencode/github@latest${envStr}
with:
model: ${provider}/${model}`,
)
)
prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`)
prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`)
}
}
}
},
})
}),
},
})
export const GithubRunCommand = effectCmd({
export const GithubRunCommand = cmd({
command: "run",
describe: "run the GitHub agent",
builder: (yargs) =>
@@ -437,10 +437,8 @@ export const GithubRunCommand = effectCmd({
type: "string",
describe: "GitHub personal access token (github_pat_********)",
}),
handler: Effect.fn("Cli.github.run")(function* (args) {
const ctx = yield* InstanceRef
if (!ctx) return yield* Effect.die("InstanceRef not provided")
yield* Effect.promise(async () => {
async handler(args) {
await bootstrap(process.cwd(), async () => {
const isMock = args.token || args.event
const context = isMock ? (JSON.parse(args.event!) as Context) : github.context
@@ -503,21 +501,21 @@ export const GithubRunCommand = effectCmd({
: "issue"
: undefined
const gitText = async (args: string[]) => {
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree })))
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree })))
if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
}
return result.text().trim()
}
const gitRun = async (args: string[]) => {
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree })))
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree })))
if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
}
return result
}
const gitStatus = (args: string[]) =>
AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree })))
AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree })))
const commitChanges = async (summary: string, actor?: string) => {
const args = ["commit", "-m", summary]
if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
@@ -881,7 +879,7 @@ export const GithubRunCommand = effectCmd({
function subscribeSessionEvents() {
const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
bash: ["Shell", UI.Style.TEXT_DANGER_BOLD],
bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
grep: ["Grep", UI.Style.TEXT_INFO_BOLD],
@@ -1647,5 +1645,5 @@ query($owner: String!, $repo: String!, $number: Int!) {
})
}
})
}),
},
})

View File

@@ -1,14 +1,17 @@
import type { Argv } from "yargs"
import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2"
import { Session } from "@/session/session"
import { MessageV2 } from "../../session/message-v2"
import { CliError, effectCmd } from "../effect-cmd"
import { cmd } from "./cmd"
import { bootstrap } from "../bootstrap"
import { Database } from "@/storage/db"
import { SessionTable, MessageTable, PartTable } from "../../session/session.sql"
import { InstanceRef } from "@/effect/instance-ref"
import { Instance } from "../../project/instance"
import { ShareNext } from "@/share/share-next"
import { EOL } from "os"
import { Filesystem } from "@/util/filesystem"
import { Effect, Schema } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { Schema } from "effect"
const decodeMessageInfo = Schema.decodeUnknownSync(MessageV2.Info)
const decodePart = Schema.decodeUnknownSync(MessageV2.Part)
@@ -75,143 +78,135 @@ export function transformShareData(shareData: ShareData[]): {
}
}
type ExportData = { info: SDKSession; messages: Array<{ info: Message; parts: Part[] }> }
export const ImportCommand = effectCmd({
export const ImportCommand = cmd({
command: "import <file>",
describe: "import session data from JSON file or URL",
builder: (yargs) =>
yargs.positional("file", {
builder: (yargs: Argv) => {
return yargs.positional("file", {
describe: "path to JSON file or share URL",
type: "string",
demandOption: true,
}),
handler: Effect.fn("Cli.import")(function* (args) {
const ctx = yield* InstanceRef
if (!ctx) return yield* Effect.die("InstanceRef not provided")
return yield* runImport(args.file, ctx.project.id)
}),
})
const runImport = Effect.fn("Cli.import.body")(function* (file: string, projectID: string) {
const share = yield* ShareNext.Service
let exportData: ExportData | undefined
const isUrl = file.startsWith("http://") || file.startsWith("https://")
if (isUrl) {
const slug = parseShareUrl(file)
if (!slug) {
const baseUrl = yield* Effect.orDie(share.url())
process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/<slug>`)
process.stdout.write(EOL)
return
}
const baseUrl = new URL(file).origin
const req = yield* Effect.orDie(share.request())
const headers = shouldAttachShareAuthHeaders(file, req.baseUrl) ? req.headers : {}
const tryFetch = (url: string) =>
Effect.tryPromise({
try: () => fetch(url, { headers }),
catch: (e) =>
new CliError({
message: `Failed to fetch share data: ${e instanceof Error ? e.message : String(e)}`,
}),
})
const dataPath = req.api.data(slug)
let response = yield* tryFetch(`${baseUrl}${dataPath}`)
if (!response.ok && dataPath !== `/api/share/${slug}/data`) {
response = yield* tryFetch(`${baseUrl}/api/share/${slug}/data`)
}
if (!response.ok) {
process.stdout.write(`Failed to fetch share data: ${response.statusText}`)
process.stdout.write(EOL)
return
}
const shareData = yield* Effect.tryPromise({
try: () => response.json() as Promise<ShareData[]>,
catch: () => new CliError({ message: "Share data was not valid JSON" }),
})
const transformed = transformShareData(shareData)
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
let exportData:
| {
info: SDKSession
messages: Array<{
info: Message
parts: Part[]
}>
}
| undefined
if (!transformed) {
process.stdout.write(`Share not found or empty: ${slug}`)
process.stdout.write(EOL)
return
}
const isUrl = args.file.startsWith("http://") || args.file.startsWith("https://")
exportData = transformed
} else {
exportData = yield* Effect.promise(() =>
Filesystem.readJson<NonNullable<typeof exportData>>(file).catch(() => undefined),
)
if (!exportData) {
process.stdout.write(`File not found: ${file}`)
process.stdout.write(EOL)
return
}
}
if (isUrl) {
const slug = parseShareUrl(args.file)
if (!slug) {
const baseUrl = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.url()))
process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/<slug>`)
process.stdout.write(EOL)
return
}
if (!exportData) {
process.stdout.write(`Failed to read session data`)
process.stdout.write(EOL)
return
}
const parsed = new URL(args.file)
const baseUrl = parsed.origin
const req = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.request()))
const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {}
const info = Schema.decodeUnknownSync(Session.Info)({
...exportData.info,
projectID,
}) as Session.Info
const row = Session.toRow(info)
Database.use((db) =>
db
.insert(SessionTable)
.values(row)
.onConflictDoUpdate({ target: SessionTable.id, set: { project_id: row.project_id } })
.run(),
)
for (const msg of exportData.messages) {
const msgInfo = decodeMessageInfo(msg.info) as MessageV2.Info
const { id, sessionID: _, ...msgData } = msgInfo
Database.use((db) =>
db
.insert(MessageTable)
.values({
id,
session_id: row.id,
time_created: msgInfo.time?.created ?? Date.now(),
data: msgData,
const dataPath = req.api.data(slug)
let response = await fetch(`${baseUrl}${dataPath}`, {
headers,
})
.onConflictDoNothing()
.run(),
)
for (const part of msg.parts) {
const partInfo = decodePart(part) as MessageV2.Part
const { id: partId, sessionID: _s, messageID, ...partData } = partInfo
if (!response.ok && dataPath !== `/api/share/${slug}/data`) {
response = await fetch(`${baseUrl}/api/share/${slug}/data`, {
headers,
})
}
if (!response.ok) {
process.stdout.write(`Failed to fetch share data: ${response.statusText}`)
process.stdout.write(EOL)
return
}
const shareData: ShareData[] = await response.json()
const transformed = transformShareData(shareData)
if (!transformed) {
process.stdout.write(`Share not found or empty: ${slug}`)
process.stdout.write(EOL)
return
}
exportData = transformed
} else {
exportData = await Filesystem.readJson<NonNullable<typeof exportData>>(args.file).catch(() => undefined)
if (!exportData) {
process.stdout.write(`File not found: ${args.file}`)
process.stdout.write(EOL)
return
}
}
if (!exportData) {
process.stdout.write(`Failed to read session data`)
process.stdout.write(EOL)
return
}
const info = Schema.decodeUnknownSync(Session.Info)({
...exportData.info,
projectID: Instance.project.id,
}) as Session.Info
const row = Session.toRow(info)
Database.use((db) =>
db
.insert(PartTable)
.values({
id: partId,
message_id: messageID,
session_id: row.id,
data: partData,
})
.onConflictDoNothing()
.insert(SessionTable)
.values(row)
.onConflictDoUpdate({ target: SessionTable.id, set: { project_id: row.project_id } })
.run(),
)
}
}
process.stdout.write(`Imported session: ${exportData.info.id}`)
process.stdout.write(EOL)
for (const msg of exportData.messages) {
const msgInfo = decodeMessageInfo(msg.info) as MessageV2.Info
const { id, sessionID: _, ...msgData } = msgInfo
Database.use((db) =>
db
.insert(MessageTable)
.values({
id,
session_id: row.id,
time_created: msgInfo.time?.created ?? Date.now(),
data: msgData,
})
.onConflictDoNothing()
.run(),
)
for (const part of msg.parts) {
const partInfo = decodePart(part) as MessageV2.Part
const { id: partId, sessionID: _s, messageID, ...partData } = partInfo
Database.use((db) =>
db
.insert(PartTable)
.values({
id: partId,
message_id: messageID,
session_id: row.id,
data: partData,
})
.onConflictDoNothing()
.run(),
)
}
}
process.stdout.write(`Imported session: ${exportData.info.id}`)
process.stdout.write(EOL)
})
},
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,16 @@
import { intro, log, outro, spinner } from "@clack/prompts"
import { Effect } from "effect"
import type { Argv } from "yargs"
import { ConfigPaths } from "@/config/paths"
import { Global } from "@opencode-ai/core/global"
import { installPlugin, patchPluginConfig, readPluginManifest } from "../../plugin/install"
import { resolvePluginTarget } from "../../plugin/shared"
import { Instance } from "../../project/instance"
import { errorMessage } from "../../util/error"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { UI } from "../ui"
import { effectCmd } from "../effect-cmd"
import { InstanceRef } from "@/effect/instance-ref"
import { cmd } from "./cmd"
type Spin = {
start: (msg: string) => void
@@ -175,12 +175,12 @@ export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps
}
}
export const PluginCommand = effectCmd({
export const PluginCommand = cmd({
command: "plugin <module>",
aliases: ["plug"],
describe: "install plugin and update config",
builder: (yargs) =>
yargs
builder: (yargs: Argv) => {
return yargs
.positional("module", {
type: "string",
describe: "npm module name",
@@ -196,8 +196,9 @@ export const PluginCommand = effectCmd({
type: "boolean",
default: false,
describe: "replace existing plugin version",
}),
handler: Effect.fn("Cli.plug")(function* (args) {
})
},
handler: async (args) => {
const mod = String(args.module ?? "").trim()
if (!mod) {
UI.error("module is required")
@@ -213,18 +214,20 @@ export const PluginCommand = effectCmd({
global: Boolean(args.global),
force: Boolean(args.force),
})
let ok = true
const ctx = yield* InstanceRef
if (!ctx) return
const ok = yield* Effect.promise(() =>
run({
vcs: ctx.project.vcs,
worktree: ctx.worktree,
directory: ctx.directory,
}),
)
await Instance.provide({
directory: process.cwd(),
fn: async () => {
ok = await run({
vcs: Instance.project.vcs,
worktree: Instance.worktree,
directory: Instance.directory,
})
},
})
outro("Done")
if (!ok) process.exitCode = 1
}),
},
})

View File

@@ -1,11 +1,11 @@
import { Effect } from "effect"
import { UI } from "../ui"
import { effectCmd, fail } from "../effect-cmd"
import { cmd } from "./cmd"
import { AppRuntime } from "@/effect/app-runtime"
import { Git } from "@/git"
import { InstanceRef } from "@/effect/instance-ref"
import { Instance } from "@/project/instance"
import { Process } from "@/util/process"
export const PrCommand = effectCmd({
export const PrCommand = cmd({
command: "pr <number>",
describe: "fetch and checkout a GitHub PR branch, then run opencode",
builder: (yargs) =>
@@ -14,102 +14,125 @@ export const PrCommand = effectCmd({
describe: "PR number to checkout",
demandOption: true,
}),
handler: Effect.fn("Cli.pr")(function* (args) {
const ctx = yield* InstanceRef
if (!ctx) return yield* fail("Could not load instance context")
if (ctx.project.vcs !== "git") {
return yield* fail("Could not find git repository. Please run this command from a git repository.")
}
const git = yield* Git.Service
const worktree = ctx.worktree
const prNumber = args.number
const localBranchName = `pr/${prNumber}`
UI.println(`Fetching and checking out PR #${prNumber}...`)
const checkout = yield* Effect.promise(() =>
Process.run(["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"], { nothrow: true }),
)
if (checkout.code !== 0) {
return yield* fail(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`)
}
const prInfoResult = yield* Effect.promise(() =>
Process.text(
[
"gh",
"pr",
"view",
`${prNumber}`,
"--json",
"headRepository,headRepositoryOwner,isCrossRepository,headRefName,body",
],
{ nothrow: true },
),
)
let sessionId: string | undefined
if (prInfoResult.code === 0 && prInfoResult.text.trim()) {
const prInfo = JSON.parse(prInfoResult.text)
if (prInfo?.isCrossRepository && prInfo.headRepository && prInfo.headRepositoryOwner) {
const forkOwner = prInfo.headRepositoryOwner.login
const forkName = prInfo.headRepository.name
const remoteName = forkOwner
const remotes = (yield* git.run(["remote"], { cwd: worktree })).text().trim()
if (!remotes.split("\n").includes(remoteName)) {
yield* git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
cwd: worktree,
})
UI.println(`Added fork remote: ${remoteName}`)
async handler(args) {
await Instance.provide({
directory: process.cwd(),
async fn() {
const project = Instance.project
if (project.vcs !== "git") {
UI.error("Could not find git repository. Please run this command from a git repository.")
process.exit(1)
}
yield* git.run(["branch", `--set-upstream-to=${remoteName}/${prInfo.headRefName}`, localBranchName], {
cwd: worktree,
})
}
const prNumber = args.number
const localBranchName = `pr/${prNumber}`
UI.println(`Fetching and checking out PR #${prNumber}...`)
if (prInfo?.body) {
const sessionMatch = prInfo.body.match(/https:\/\/opncd\.ai\/s\/([a-zA-Z0-9_-]+)/)
if (sessionMatch) {
const sessionUrl = sessionMatch[0]
UI.println(`Found opencode session: ${sessionUrl}`)
UI.println(`Importing session...`)
// Use gh pr checkout with custom branch name
const result = await Process.run(
["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"],
{
nothrow: true,
},
)
const importResult = yield* Effect.promise(() =>
Process.text(["opencode", "import", sessionUrl], { nothrow: true }),
)
if (importResult.code === 0) {
const sessionIdMatch = importResult.text.trim().match(/Imported session: ([a-zA-Z0-9_-]+)/)
if (sessionIdMatch) {
sessionId = sessionIdMatch[1]
UI.println(`Session imported: ${sessionId}`)
if (result.code !== 0) {
UI.error(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`)
process.exit(1)
}
// Fetch PR info for fork handling and session link detection
const prInfoResult = await Process.text(
[
"gh",
"pr",
"view",
`${prNumber}`,
"--json",
"headRepository,headRepositoryOwner,isCrossRepository,headRefName,body",
],
{ nothrow: true },
)
let sessionId: string | undefined
if (prInfoResult.code === 0) {
const prInfoText = prInfoResult.text
if (prInfoText.trim()) {
const prInfo = JSON.parse(prInfoText)
// Handle fork PRs
if (prInfo && prInfo.isCrossRepository && prInfo.headRepository && prInfo.headRepositoryOwner) {
const forkOwner = prInfo.headRepositoryOwner.login
const forkName = prInfo.headRepository.name
const remoteName = forkOwner
// Check if remote already exists
const remotes = await AppRuntime.runPromise(
Git.Service.use((git) => git.run(["remote"], { cwd: Instance.worktree })),
).then((x) => x.text().trim())
if (!remotes.split("\n").includes(remoteName)) {
await AppRuntime.runPromise(
Git.Service.use((git) =>
git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
cwd: Instance.worktree,
}),
),
)
UI.println(`Added fork remote: ${remoteName}`)
}
// Set upstream to the fork so pushes go there
const headRefName = prInfo.headRefName
await AppRuntime.runPromise(
Git.Service.use((git) =>
git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
cwd: Instance.worktree,
}),
),
)
}
// Check for opencode session link in PR body
if (prInfo && prInfo.body) {
const sessionMatch = prInfo.body.match(/https:\/\/opncd\.ai\/s\/([a-zA-Z0-9_-]+)/)
if (sessionMatch) {
const sessionUrl = sessionMatch[0]
UI.println(`Found opencode session: ${sessionUrl}`)
UI.println(`Importing session...`)
const importResult = await Process.text(["opencode", "import", sessionUrl], {
nothrow: true,
})
if (importResult.code === 0) {
const importOutput = importResult.text.trim()
// Extract session ID from the output (format: "Imported session: <session-id>")
const sessionIdMatch = importOutput.match(/Imported session: ([a-zA-Z0-9_-]+)/)
if (sessionIdMatch) {
sessionId = sessionIdMatch[1]
UI.println(`Session imported: ${sessionId}`)
}
}
}
}
}
}
}
}
UI.println(`Successfully checked out PR #${prNumber} as branch '${localBranchName}'`)
UI.println()
UI.println("Starting opencode...")
UI.println()
UI.println(`Successfully checked out PR #${prNumber} as branch '${localBranchName}'`)
UI.println()
UI.println("Starting opencode...")
UI.println()
const opencodeArgs = sessionId ? ["-s", sessionId] : []
const code = yield* Effect.promise(
() =>
Process.spawn(["opencode", ...opencodeArgs], {
const opencodeArgs = sessionId ? ["-s", sessionId] : []
const opencodeProcess = Process.spawn(["opencode", ...opencodeArgs], {
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
cwd: process.cwd(),
}).exited,
)
// Match legacy throw semantics — propagate as a defect so the top-level
// index.ts catch handles it identically (exit 1, "Unexpected error" banner).
if (code !== 0) return yield* Effect.die(new Error(`opencode exited with code ${code}`))
}),
})
const code = await opencodeProcess.exited
if (code !== 0) throw new Error(`opencode exited with code ${code}`)
},
})
},
})

View File

@@ -1,7 +1,6 @@
import { Auth } from "../../auth"
import { AppRuntime } from "../../effect/app-runtime"
import { cmd } from "./cmd"
import { effectCmd } from "../effect-cmd"
import * as prompts from "@clack/prompts"
import { UI } from "../ui"
import { ModelsDev } from "@/provider/models"
@@ -14,6 +13,7 @@ import os from "os"
import { Config } from "@/config/config"
import { Global } from "@opencode-ai/core/global"
import { Plugin } from "../../plugin"
import { Instance } from "../../project/instance"
import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "@/util/process"
import { text } from "node:stream/consumers"
@@ -232,59 +232,58 @@ export const ProvidersCommand = cmd({
async handler() {},
})
export const ProvidersListCommand = effectCmd({
export const ProvidersListCommand = cmd({
command: "list",
aliases: ["ls"],
describe: "list providers and credentials",
// Lists global credentials + provider env vars; no project instance needed.
instance: false,
handler: Effect.fn("Cli.providers.list")(function* (_args) {
const authSvc = yield* Auth.Service
const modelsDev = yield* ModelsDev.Service
yield* Effect.promise(async () => {
async handler(_args) {
UI.empty()
const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir()
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
const results = await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
return Object.entries(yield* auth.all())
}),
)
const database = await getModels()
for (const [providerID, result] of results) {
const name = database[providerID]?.name || providerID
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
}
prompts.outro(`${results.length} credentials`)
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
for (const [providerID, provider] of Object.entries(database)) {
for (const envVar of provider.env) {
if (process.env[envVar]) {
activeEnvVars.push({
provider: provider.name || providerID,
envVar,
})
}
}
}
if (activeEnvVars.length > 0) {
UI.empty()
const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir()
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
const results = Object.entries(await Effect.runPromise(authSvc.all()))
const database = await Effect.runPromise(modelsDev.get())
prompts.intro("Environment")
for (const [providerID, result] of results) {
const name = database[providerID]?.name || providerID
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
for (const { provider, envVar } of activeEnvVars) {
prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
}
prompts.outro(`${results.length} credentials`)
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
for (const [providerID, provider] of Object.entries(database)) {
for (const envVar of provider.env) {
if (process.env[envVar]) {
activeEnvVars.push({
provider: provider.name || providerID,
envVar,
})
}
}
}
if (activeEnvVars.length > 0) {
UI.empty()
prompts.intro("Environment")
for (const { provider, envVar } of activeEnvVars) {
prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
}
prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
}
})
}),
prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
}
},
})
export const ProvidersLoginCommand = effectCmd({
export const ProvidersLoginCommand = cmd({
command: "login [url]",
describe: "log in to a provider",
builder: (yargs) =>
@@ -303,219 +302,228 @@ export const ProvidersLoginCommand = effectCmd({
describe: "login method label (skips method selection)",
type: "string",
}),
handler: Effect.fn("Cli.providers.login")(function* (args) {
const cfgSvc = yield* Config.Service
const pluginSvc = yield* Plugin.Service
yield* Effect.promise(async () => {
UI.empty()
prompts.intro("Add credential")
if (args.url) {
const url = args.url.replace(/\/+$/, "")
const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as {
auth: { command: string[]; env: string }
}
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const proc = Process.spawn(wellknown.auth.command, {
stdout: "pipe",
stderr: "inherit",
})
if (!proc.stdout) {
prompts.log.error("Failed")
prompts.outro("Done")
return
}
const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)])
if (exit !== 0) {
prompts.log.error("Failed")
prompts.outro("Done")
return
}
await put(url, {
type: "wellknown",
key: wellknown.auth.env,
token: token.trim(),
})
prompts.log.success("Logged into " + url)
prompts.outro("Done")
return
}
await refreshModels().catch(() => {})
const config = await Effect.runPromise(cfgSvc.get())
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const providers = await getModels().then((x) => {
const filtered: Record<string, (typeof x)[string]> = {}
for (const [key, value] of Object.entries(x)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
filtered[key] = value
async handler(args) {
await Instance.provide({
directory: process.cwd(),
async fn() {
UI.empty()
prompts.intro("Add credential")
if (args.url) {
const url = args.url.replace(/\/+$/, "")
const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as {
auth: { command: string[]; env: string }
}
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const proc = Process.spawn(wellknown.auth.command, {
stdout: "pipe",
})
if (!proc.stdout) {
prompts.log.error("Failed")
prompts.outro("Done")
return
}
const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)])
if (exit !== 0) {
prompts.log.error("Failed")
prompts.outro("Done")
return
}
await put(url, {
type: "wellknown",
key: wellknown.auth.env,
token: token.trim(),
})
prompts.log.success("Logged into " + url)
prompts.outro("Done")
return
}
return filtered
})
const hooks = await Effect.runPromise(pluginSvc.list())
await refreshModels().catch(() => {})
const priority: Record<string, number> = {
opencode: 0,
openai: 1,
"github-copilot": 2,
google: 3,
anthropic: 4,
openrouter: 5,
vercel: 6,
}
const pluginProviders = resolvePluginProviders({
hooks,
existingProviders: providers,
disabled,
enabled,
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
})
const options = [
...pipe(
providers,
values(),
sortBy(
(x) => priority[x.id] ?? 99,
(x) => x.name ?? x.id,
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const providers = await getModels().then((x) => {
const filtered: Record<string, (typeof x)[string]> = {}
for (const [key, value] of Object.entries(x)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
filtered[key] = value
}
}
return filtered
})
const hooks = await AppRuntime.runPromise(
Effect.gen(function* () {
const plugin = yield* Plugin.Service
return yield* plugin.list()
}),
)
const priority: Record<string, number> = {
opencode: 0,
openai: 1,
"github-copilot": 2,
google: 3,
anthropic: 4,
openrouter: 5,
vercel: 6,
}
const pluginProviders = resolvePluginProviders({
hooks,
existingProviders: providers,
disabled,
enabled,
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
})
const options = [
...pipe(
providers,
values(),
sortBy(
(x) => priority[x.id] ?? 99,
(x) => x.name ?? x.id,
),
map((x) => ({
label: x.name,
value: x.id,
hint: {
opencode: "recommended",
openai: "ChatGPT Plus/Pro or API key",
}[x.id],
})),
),
map((x) => ({
...pluginProviders.map((x) => ({
label: x.name,
value: x.id,
hint: {
opencode: "recommended",
openai: "ChatGPT Plus/Pro or API key",
}[x.id],
hint: "plugin",
})),
),
...pluginProviders.map((x) => ({
label: x.name,
value: x.id,
hint: "plugin",
})),
]
]
let provider: string
if (args.provider) {
const input = args.provider
const byID = options.find((x) => x.value === input)
const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase())
const match = byID ?? byName
if (!match) {
prompts.log.error(`Unknown provider "${input}"`)
process.exit(1)
let provider: string
if (args.provider) {
const input = args.provider
const byID = options.find((x) => x.value === input)
const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase())
const match = byID ?? byName
if (!match) {
prompts.log.error(`Unknown provider "${input}"`)
process.exit(1)
}
provider = match.value
} else {
const selected = await prompts.autocomplete({
message: "Select provider",
maxItems: 8,
options: [
...options,
{
value: "other",
label: "Other",
},
],
})
if (prompts.isCancel(selected)) throw new UI.CancelledError()
provider = selected as string
}
provider = match.value
} else {
const selected = await prompts.autocomplete({
message: "Select provider",
maxItems: 8,
options: [
...options,
{
value: "other",
label: "Other",
},
],
})
if (prompts.isCancel(selected)) throw new UI.CancelledError()
provider = selected as string
}
const plugin = hooks.findLast((x) => x.auth?.provider === provider)
if (plugin && plugin.auth) {
const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method)
if (handled) return
}
if (provider === "other") {
const custom = await prompts.text({
message: "Enter provider id",
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
})
if (prompts.isCancel(custom)) throw new UI.CancelledError()
provider = custom.replace(/^@ai-sdk\//, "")
const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
if (customPlugin && customPlugin.auth) {
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
const plugin = hooks.findLast((x) => x.auth?.provider === provider)
if (plugin && plugin.auth) {
const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method)
if (handled) return
}
prompts.log.warn(
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
)
}
if (provider === "other") {
const custom = await prompts.text({
message: "Enter provider id",
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
})
if (prompts.isCancel(custom)) throw new UI.CancelledError()
provider = custom.replace(/^@ai-sdk\//, "")
if (provider === "amazon-bedrock") {
prompts.log.info(
"Amazon Bedrock authentication priority:\n" +
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
" 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
"Configure via opencode.json options (profile, region, endpoint) or\n" +
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).",
)
}
const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
if (customPlugin && customPlugin.auth) {
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
if (handled) return
}
if (provider === "opencode") {
prompts.log.info("Create an api key at https://opencode.ai/auth")
}
prompts.log.warn(
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
)
}
if (provider === "vercel") {
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
}
if (provider === "amazon-bedrock") {
prompts.log.info(
"Amazon Bedrock authentication priority:\n" +
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
" 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
"Configure via opencode.json options (profile, region, endpoint) or\n" +
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).",
)
}
if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
prompts.log.info(
"Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway",
)
}
if (provider === "opencode") {
prompts.log.info("Create an api key at https://opencode.ai/auth")
}
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
await put(provider, {
type: "api",
key,
})
if (provider === "vercel") {
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
}
prompts.outro("Done")
if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
prompts.log.info(
"Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway",
)
}
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
await put(provider, {
type: "api",
key,
})
prompts.outro("Done")
},
})
}),
},
})
export const ProvidersLogoutCommand = effectCmd({
export const ProvidersLogoutCommand = cmd({
command: "logout",
describe: "log out from a configured provider",
// Removes a global auth credential; no project instance needed.
instance: false,
handler: Effect.fn("Cli.providers.logout")(function* (_args) {
const authSvc = yield* Auth.Service
const modelsDev = yield* ModelsDev.Service
yield* Effect.promise(async () => {
UI.empty()
const credentials: Array<[string, Auth.Info]> = Object.entries(await Effect.runPromise(authSvc.all()))
prompts.intro("Remove credential")
if (credentials.length === 0) {
prompts.log.error("No credentials found")
return
}
const database = await Effect.runPromise(modelsDev.get())
const selected = await prompts.select({
message: "Select provider",
options: credentials.map(([key, value]) => ({
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
value: key,
})),
})
if (prompts.isCancel(selected)) throw new UI.CancelledError()
const providerID = selected as string
await Effect.runPromise(authSvc.remove(providerID))
prompts.outro("Logout successful")
async handler(_args) {
UI.empty()
const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
return Object.entries(yield* auth.all())
}),
)
prompts.intro("Remove credential")
if (credentials.length === 0) {
prompts.log.error("No credentials found")
return
}
const database = await getModels()
const selected = await prompts.select({
message: "Select provider",
options: credentials.map(([key, value]) => ({
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
value: key,
})),
})
}),
if (prompts.isCancel(selected)) throw new UI.CancelledError()
const providerID = selected as string
await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.remove(providerID)
}),
)
prompts.outro("Logout successful")
},
})

View File

@@ -1,11 +1,10 @@
import type { Argv } from "yargs"
import path from "path"
import { pathToFileURL } from "url"
import { Effect } from "effect"
import { UI } from "../ui"
import { effectCmd } from "../effect-cmd"
import { cmd } from "./cmd"
import { Flag } from "@opencode-ai/core/flag/flag"
import { ServerAuth } from "@/server/auth"
import { bootstrap } from "../bootstrap"
import { EOL } from "os"
import { Filesystem } from "@/util/filesystem"
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
@@ -23,8 +22,7 @@ import { WriteTool } from "../../tool/write"
import { WebSearchTool } from "../../tool/websearch"
import { TaskTool } from "../../tool/task"
import { SkillTool } from "../../tool/skill"
import { ShellTool } from "../../tool/shell"
import { ShellID } from "../../tool/shell/id"
import { BashTool } from "../../tool/bash"
import { TodoWriteTool } from "../../tool/todo"
import { Locale } from "@/util/locale"
import { AppRuntime } from "@/effect/app-runtime"
@@ -177,7 +175,7 @@ function skill(info: ToolProps<typeof SkillTool>) {
})
}
function shell(info: ToolProps<typeof ShellTool>) {
function bash(info: ToolProps<typeof BashTool>) {
const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined
block(
{
@@ -204,17 +202,11 @@ function normalizePath(input?: string) {
return input
}
export const RunCommand = effectCmd({
export const RunCommand = cmd({
command: "run [message..]",
describe: "run opencode with a message",
// --attach connects to a remote server (no local instance needed); the
// default path runs an in-process server and needs the project instance.
instance: (args) => !args.attach,
// For --dir without --attach, load instance for the resolved target dir.
// The handler also chdirs (preserving the legacy order: chdir → file resolution).
directory: (args) => (args.dir && !args.attach ? path.resolve(process.cwd(), args.dir) : process.cwd()),
builder: (yargs: Argv) =>
yargs
builder: (yargs: Argv) => {
return yargs
.positional("message", {
describe: "message to send",
type: "string",
@@ -298,313 +290,291 @@ export const RunCommand = effectCmd({
type: "boolean",
describe: "auto-approve permissions that are not explicitly denied (dangerous!)",
default: false,
}),
handler: Effect.fn("Cli.run")(function* (args) {
yield* Effect.promise(async () => {
let message = [...args.message, ...(args["--"] || [])]
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
.join(" ")
})
},
handler: async (args) => {
let message = [...args.message, ...(args["--"] || [])]
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
.join(" ")
const directory = (() => {
if (!args.dir) return undefined
if (args.attach) return args.dir
try {
process.chdir(args.dir)
return process.cwd()
} catch {
UI.error("Failed to change directory to " + args.dir)
const directory = (() => {
if (!args.dir) return undefined
if (args.attach) return args.dir
try {
process.chdir(args.dir)
return process.cwd()
} catch {
UI.error("Failed to change directory to " + args.dir)
process.exit(1)
}
})()
const files: { type: "file"; url: string; filename: string; mime: string }[] = []
if (args.file) {
const list = Array.isArray(args.file) ? args.file : [args.file]
for (const filePath of list) {
const resolvedPath = path.resolve(process.cwd(), filePath)
if (!(await Filesystem.exists(resolvedPath))) {
UI.error(`File not found: ${filePath}`)
process.exit(1)
}
})()
const files: { type: "file"; url: string; filename: string; mime: string }[] = []
if (args.file) {
const list = Array.isArray(args.file) ? args.file : [args.file]
const mime = (await Filesystem.isDir(resolvedPath)) ? "application/x-directory" : "text/plain"
for (const filePath of list) {
const resolvedPath = path.resolve(process.cwd(), filePath)
if (!(await Filesystem.exists(resolvedPath))) {
UI.error(`File not found: ${filePath}`)
process.exit(1)
}
const mime = (await Filesystem.isDir(resolvedPath)) ? "application/x-directory" : "text/plain"
files.push({
type: "file",
url: pathToFileURL(resolvedPath).href,
filename: path.basename(resolvedPath),
mime,
})
}
}
if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text())
if (message.trim().length === 0 && !args.command) {
UI.error("You must provide a message or a command")
process.exit(1)
}
if (args.fork && !args.continue && !args.session) {
UI.error("--fork requires --continue or --session")
process.exit(1)
}
const rules: Permission.Ruleset = [
{
permission: "question",
action: "deny",
pattern: "*",
},
{
permission: "plan_enter",
action: "deny",
pattern: "*",
},
{
permission: "plan_exit",
action: "deny",
pattern: "*",
},
]
function title() {
if (args.title === undefined) return
if (args.title !== "") return args.title
return message.slice(0, 50) + (message.length > 50 ? "..." : "")
}
async function session(sdk: OpencodeClient) {
const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session
if (baseID && args.fork) {
const forked = await sdk.session.fork({ sessionID: baseID })
return forked.data?.id
}
if (baseID) return baseID
const name = title()
const result = await sdk.session.create({ title: name, permission: rules })
return result.data?.id
}
async function share(sdk: OpencodeClient, sessionID: string) {
const cfg = await sdk.config.get()
if (!cfg.data) return
if (cfg.data.share !== "auto" && !Flag.OPENCODE_AUTO_SHARE && !args.share) return
const res = await sdk.session.share({ sessionID }).catch((error) => {
if (error instanceof Error && error.message.includes("disabled")) {
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
}
return { error }
files.push({
type: "file",
url: pathToFileURL(resolvedPath).href,
filename: path.basename(resolvedPath),
mime,
})
if (!res.error && "data" in res && res.data?.share?.url) {
UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url)
}
}
if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text())
if (message.trim().length === 0 && !args.command) {
UI.error("You must provide a message or a command")
process.exit(1)
}
if (args.fork && !args.continue && !args.session) {
UI.error("--fork requires --continue or --session")
process.exit(1)
}
const rules: Permission.Ruleset = [
{
permission: "question",
action: "deny",
pattern: "*",
},
{
permission: "plan_enter",
action: "deny",
pattern: "*",
},
{
permission: "plan_exit",
action: "deny",
pattern: "*",
},
]
function title() {
if (args.title === undefined) return
if (args.title !== "") return args.title
return message.slice(0, 50) + (message.length > 50 ? "..." : "")
}
async function session(sdk: OpencodeClient) {
const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session
if (baseID && args.fork) {
const forked = await sdk.session.fork({ sessionID: baseID })
return forked.data?.id
}
if (baseID) return baseID
const name = title()
const result = await sdk.session.create({ title: name, permission: rules })
return result.data?.id
}
async function share(sdk: OpencodeClient, sessionID: string) {
const cfg = await sdk.config.get()
if (!cfg.data) return
if (cfg.data.share !== "auto" && !Flag.OPENCODE_AUTO_SHARE && !args.share) return
const res = await sdk.session.share({ sessionID }).catch((error) => {
if (error instanceof Error && error.message.includes("disabled")) {
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
}
return { error }
})
if (!res.error && "data" in res && res.data?.share?.url) {
UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url)
}
}
async function execute(sdk: OpencodeClient) {
function tool(part: ToolPart) {
try {
if (part.tool === "bash") return bash(props<typeof BashTool>(part))
if (part.tool === "glob") return glob(props<typeof GlobTool>(part))
if (part.tool === "grep") return grep(props<typeof GrepTool>(part))
if (part.tool === "read") return read(props<typeof ReadTool>(part))
if (part.tool === "write") return write(props<typeof WriteTool>(part))
if (part.tool === "webfetch") return webfetch(props<typeof WebFetchTool>(part))
if (part.tool === "edit") return edit(props<typeof EditTool>(part))
if (part.tool === "websearch") return websearch(props<typeof WebSearchTool>(part))
if (part.tool === "task") return task(props<typeof TaskTool>(part))
if (part.tool === "todowrite") return todo(props<typeof TodoWriteTool>(part))
if (part.tool === "skill") return skill(props<typeof SkillTool>(part))
return fallback(part)
} catch {
return fallback(part)
}
}
async function execute(sdk: OpencodeClient) {
function tool(part: ToolPart) {
try {
if (part.tool === ShellID.ToolID) return shell(props<typeof ShellTool>(part))
if (part.tool === "glob") return glob(props<typeof GlobTool>(part))
if (part.tool === "grep") return grep(props<typeof GrepTool>(part))
if (part.tool === "read") return read(props<typeof ReadTool>(part))
if (part.tool === "write") return write(props<typeof WriteTool>(part))
if (part.tool === "webfetch") return webfetch(props<typeof WebFetchTool>(part))
if (part.tool === "edit") return edit(props<typeof EditTool>(part))
if (part.tool === "websearch") return websearch(props<typeof WebSearchTool>(part))
if (part.tool === "task") return task(props<typeof TaskTool>(part))
if (part.tool === "todowrite") return todo(props<typeof TodoWriteTool>(part))
if (part.tool === "skill") return skill(props<typeof SkillTool>(part))
return fallback(part)
} catch {
return fallback(part)
}
function emit(type: string, data: Record<string, unknown>) {
if (args.format === "json") {
process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
return true
}
return false
}
function emit(type: string, data: Record<string, unknown>) {
if (args.format === "json") {
process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
return true
const events = await sdk.event.subscribe()
let error: string | undefined
async function loop() {
const toggles = new Map<string, boolean>()
for await (const event of events.stream) {
if (
event.type === "message.updated" &&
event.properties.info.role === "assistant" &&
args.format !== "json" &&
toggles.get("start") !== true
) {
UI.empty()
UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`)
UI.empty()
toggles.set("start", true)
}
return false
}
const events = await sdk.event.subscribe()
let error: string | undefined
if (event.type === "message.part.updated") {
const part = event.properties.part
if (part.sessionID !== sessionID) continue
async function loop() {
const toggles = new Map<string, boolean>()
for await (const event of events.stream) {
if (
event.type === "message.updated" &&
event.properties.info.role === "assistant" &&
args.format !== "json" &&
toggles.get("start") !== true
) {
UI.empty()
UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`)
UI.empty()
toggles.set("start", true)
}
if (event.type === "message.part.updated") {
const part = event.properties.part
if (part.sessionID !== sessionID) continue
if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) {
if (emit("tool_use", { part })) continue
if (part.state.status === "completed") {
tool(part)
continue
}
inline({
icon: "✗",
title: `${part.tool} failed`,
})
UI.error(part.state.error)
if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) {
if (emit("tool_use", { part })) continue
if (part.state.status === "completed") {
tool(part)
continue
}
if (
part.type === "tool" &&
part.tool === "task" &&
part.state.status === "running" &&
args.format !== "json"
) {
if (toggles.get(part.id) === true) continue
task(props<typeof TaskTool>(part))
toggles.set(part.id, true)
}
if (part.type === "step-start") {
if (emit("step_start", { part })) continue
}
if (part.type === "step-finish") {
if (emit("step_finish", { part })) continue
}
if (part.type === "text" && part.time?.end) {
if (emit("text", { part })) continue
const text = part.text.trim()
if (!text) continue
if (!process.stdout.isTTY) {
process.stdout.write(text + EOL)
continue
}
UI.empty()
UI.println(text)
UI.empty()
}
if (part.type === "reasoning" && part.time?.end && args.thinking) {
if (emit("reasoning", { part })) continue
const text = part.text.trim()
if (!text) continue
const line = `Thinking: ${text}`
if (process.stdout.isTTY) {
UI.empty()
UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
UI.empty()
continue
}
process.stdout.write(line + EOL)
}
}
if (event.type === "session.error") {
const props = event.properties
if (props.sessionID !== sessionID || !props.error) continue
let err = String(props.error.name)
if ("data" in props.error && props.error.data && "message" in props.error.data) {
err = String(props.error.data.message)
}
error = error ? error + EOL + err : err
if (emit("error", { error: props.error })) continue
UI.error(err)
inline({
icon: "✗",
title: `${part.tool} failed`,
})
UI.error(part.state.error)
}
if (
event.type === "session.status" &&
event.properties.sessionID === sessionID &&
event.properties.status.type === "idle"
part.type === "tool" &&
part.tool === "task" &&
part.state.status === "running" &&
args.format !== "json"
) {
break
if (toggles.get(part.id) === true) continue
task(props<typeof TaskTool>(part))
toggles.set(part.id, true)
}
if (event.type === "permission.asked") {
const permission = event.properties
if (permission.sessionID !== sessionID) continue
if (part.type === "step-start") {
if (emit("step_start", { part })) continue
}
if (args["dangerously-skip-permissions"]) {
await sdk.permission.reply({
requestID: permission.id,
reply: "once",
})
} else {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL +
`permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
)
await sdk.permission.reply({
requestID: permission.id,
reply: "reject",
})
if (part.type === "step-finish") {
if (emit("step_finish", { part })) continue
}
if (part.type === "text" && part.time?.end) {
if (emit("text", { part })) continue
const text = part.text.trim()
if (!text) continue
if (!process.stdout.isTTY) {
process.stdout.write(text + EOL)
continue
}
UI.empty()
UI.println(text)
UI.empty()
}
if (part.type === "reasoning" && part.time?.end && args.thinking) {
if (emit("reasoning", { part })) continue
const text = part.text.trim()
if (!text) continue
const line = `Thinking: ${text}`
if (process.stdout.isTTY) {
UI.empty()
UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
UI.empty()
continue
}
process.stdout.write(line + EOL)
}
}
if (event.type === "session.error") {
const props = event.properties
if (props.sessionID !== sessionID || !props.error) continue
let err = String(props.error.name)
if ("data" in props.error && props.error.data && "message" in props.error.data) {
err = String(props.error.data.message)
}
error = error ? error + EOL + err : err
if (emit("error", { error: props.error })) continue
UI.error(err)
}
if (
event.type === "session.status" &&
event.properties.sessionID === sessionID &&
event.properties.status.type === "idle"
) {
break
}
if (event.type === "permission.asked") {
const permission = event.properties
if (permission.sessionID !== sessionID) continue
if (args["dangerously-skip-permissions"]) {
await sdk.permission.reply({
requestID: permission.id,
reply: "once",
})
} else {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL +
`permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
)
await sdk.permission.reply({
requestID: permission.id,
reply: "reject",
})
}
}
}
}
// Validate agent if specified
const agent = await (async () => {
if (!args.agent) return undefined
const name = args.agent
// Validate agent if specified
const agent = await (async () => {
if (!args.agent) return undefined
const name = args.agent
// When attaching, validate against the running server instead of local Instance state.
if (args.attach) {
const modes = await sdk.app
.agents(undefined, { throwOnError: true })
.then((x) => x.data ?? [])
.catch(() => undefined)
// When attaching, validate against the running server instead of local Instance state.
if (args.attach) {
const modes = await sdk.app
.agents(undefined, { throwOnError: true })
.then((x) => x.data ?? [])
.catch(() => undefined)
if (!modes) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`failed to list agents from ${args.attach}. Falling back to default agent`,
)
return undefined
}
const agent = modes.find((a) => a.name === name)
if (!agent) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`agent "${name}" not found. Falling back to default agent`,
)
return undefined
}
if (agent.mode === "subagent") {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
)
return undefined
}
return name
if (!modes) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`failed to list agents from ${args.attach}. Falling back to default agent`,
)
return undefined
}
const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name)))
if (!entry) {
const agent = modes.find((a) => a.name === name)
if (!agent) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
@@ -612,7 +582,8 @@ export const RunCommand = effectCmd({
)
return undefined
}
if (entry.mode === "subagent") {
if (agent.mode === "subagent") {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
@@ -620,48 +591,76 @@ export const RunCommand = effectCmd({
)
return undefined
}
return name
})()
const sessionID = await session(sdk)
if (!sessionID) {
UI.error("Session not found")
process.exit(1)
}
await share(sdk, sessionID)
loop().catch((e) => {
console.error(e)
process.exit(1)
const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name)))
if (!entry) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`agent "${name}" not found. Falling back to default agent`,
)
return undefined
}
if (entry.mode === "subagent") {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
)
return undefined
}
return name
})()
const sessionID = await session(sdk)
if (!sessionID) {
UI.error("Session not found")
process.exit(1)
}
await share(sdk, sessionID)
loop().catch((e) => {
console.error(e)
process.exit(1)
})
if (args.command) {
await sdk.session.command({
sessionID,
agent,
model: args.model,
command: args.command,
arguments: message,
variant: args.variant,
})
} else {
const model = args.model ? Provider.parseModel(args.model) : undefined
await sdk.session.prompt({
sessionID,
agent,
model,
variant: args.variant,
parts: [...files, { type: "text", text: message }],
})
if (args.command) {
await sdk.session.command({
sessionID,
agent,
model: args.model,
command: args.command,
arguments: message,
variant: args.variant,
})
} else {
const model = args.model ? Provider.parseModel(args.model) : undefined
await sdk.session.prompt({
sessionID,
agent,
model,
variant: args.variant,
parts: [...files, { type: "text", text: message }],
})
}
}
}
if (args.attach) {
const headers = ServerAuth.headers({ password: args.password })
const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers })
return await execute(sdk)
}
if (args.attach) {
const headers = (() => {
const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
if (!password) return undefined
const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode"
const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
return { Authorization: auth }
})()
const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers })
return await execute(sdk)
}
await bootstrap(process.cwd(), async () => {
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
return Server.Default().app.fetch(request)
@@ -669,5 +668,5 @@ export const RunCommand = effectCmd({
const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn })
await execute(sdk)
})
}),
},
})

View File

@@ -1,24 +1,21 @@
import { Effect } from "effect"
import { Server } from "../../server/server"
import { effectCmd } from "../effect-cmd"
import { cmd } from "./cmd"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { Flag } from "@opencode-ai/core/flag/flag"
export const ServeCommand = effectCmd({
export const ServeCommand = cmd({
command: "serve",
builder: (yargs) => withNetworkOptions(yargs),
describe: "starts a headless opencode server",
// Server loads instances per-request via x-opencode-directory header — no
// need for an ambient project InstanceContext at startup.
instance: false,
handler: Effect.fn("Cli.serve")(function* (args) {
handler: async (args) => {
if (!Flag.OPENCODE_SERVER_PASSWORD) {
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = yield* Effect.promise(() => resolveNetworkOptions(args))
const server = yield* Effect.promise(() => Server.listen(opts))
const opts = await resolveNetworkOptions(args)
const server = await Server.listen(opts)
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
yield* Effect.never
}),
await new Promise(() => {})
await server.stop()
},
})

View File

@@ -1,9 +1,8 @@
import type { Argv } from "yargs"
import { Effect } from "effect"
import { cmd } from "./cmd"
import { effectCmd, fail } from "../effect-cmd"
import { Session } from "@/session/session"
import { SessionID } from "../../session/schema"
import { bootstrap } from "../bootstrap"
import { UI } from "../ui"
import { Locale } from "@/util/locale"
import { Flag } from "@opencode-ai/core/flag/flag"
@@ -12,6 +11,7 @@ import { Process } from "@/util/process"
import { EOL } from "os"
import path from "path"
import { which } from "../../util/which"
import { AppRuntime } from "@/effect/app-runtime"
function pagerCmd(): string[] {
const lessOptions = ["-R", "-S"]
@@ -47,30 +47,36 @@ export const SessionCommand = cmd({
async handler() {},
})
export const SessionDeleteCommand = effectCmd({
export const SessionDeleteCommand = cmd({
command: "delete <sessionID>",
describe: "delete a session",
builder: (yargs) =>
yargs.positional("sessionID", {
builder: (yargs: Argv) => {
return yargs.positional("sessionID", {
describe: "session ID to delete",
type: "string",
demandOption: true,
}),
handler: Effect.fn("Cli.session.delete")(function* (args) {
const svc = yield* Session.Service
const sessionID = SessionID.make(args.sessionID)
// Match legacy try/catch — Session.get surfaces NotFoundError as a defect.
yield* svc.get(sessionID).pipe(Effect.catchCause(() => fail(`Session not found: ${args.sessionID}`)))
yield* svc.remove(sessionID)
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL)
}),
})
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
const sessionID = SessionID.make(args.sessionID)
try {
await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID)))
} catch {
UI.error(`Session not found: ${args.sessionID}`)
process.exit(1)
}
await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(sessionID)))
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL)
})
},
})
export const SessionListCommand = effectCmd({
export const SessionListCommand = cmd({
command: "list",
describe: "list sessions",
builder: (yargs) =>
yargs
builder: (yargs: Argv) => {
return yargs
.option("max-count", {
alias: "n",
describe: "limit to N most recent sessions",
@@ -81,18 +87,28 @@ export const SessionListCommand = effectCmd({
type: "string",
choices: ["table", "json"],
default: "table",
}),
handler: Effect.fn("Cli.session.list")(function* (args) {
const sessions = yield* Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount }))
})
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
const sessions = await AppRuntime.runPromise(
Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount })),
)
if (sessions.length === 0) return
if (sessions.length === 0) {
return
}
const output = args.format === "json" ? formatSessionJSON(sessions) : formatSessionTable(sessions)
let output: string
if (args.format === "json") {
output = formatSessionJSON(sessions)
} else {
output = formatSessionTable(sessions)
}
const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table"
const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table"
if (shouldPaginate) {
yield* Effect.promise(async () => {
if (shouldPaginate) {
const proc = Process.spawn(pagerCmd(), {
stdin: "pipe",
stdout: "inherit",
@@ -107,11 +123,11 @@ export const SessionListCommand = effectCmd({
proc.stdin.write(output)
proc.stdin.end()
await proc.exited
})
} else {
console.log(output)
}
}),
} else {
console.log(output)
}
})
},
})
function formatSessionTable(sessions: Session.Info[]): string {

View File

@@ -1,10 +1,12 @@
import { Effect } from "effect"
import { effectCmd } from "../effect-cmd"
import type { Argv } from "yargs"
import { cmd } from "./cmd"
import { Session } from "@/session/session"
import { bootstrap } from "../bootstrap"
import { Database } from "@/storage/db"
import { SessionTable } from "../../session/session.sql"
import { Project } from "@/project/project"
import { InstanceRef } from "@/effect/instance-ref"
import { Instance } from "../../project/instance"
import { AppRuntime } from "@/effect/app-runtime"
interface SessionStats {
totalSessions: number
@@ -45,11 +47,11 @@ interface SessionStats {
medianTokensPerSession: number
}
export const StatsCommand = effectCmd({
export const StatsCommand = cmd({
command: "stats",
describe: "show token usage and cost statistics",
builder: (yargs) =>
yargs
builder: (yargs: Argv) => {
return yargs
.option("days", {
describe: "show stats for the last N days (default: all time)",
type: "number",
@@ -64,32 +66,35 @@ export const StatsCommand = effectCmd({
.option("project", {
describe: "filter by project (default: all projects, empty string: current project)",
type: "string",
}),
handler: Effect.fn("Cli.stats")(function* (args) {
const ctx = yield* InstanceRef
if (!ctx) return
const stats = yield* aggregateSessionStats(args.days, args.project, ctx.project)
let modelLimit: number | undefined
if (args.models === true) {
modelLimit = Infinity
} else if (typeof args.models === "number") {
modelLimit = args.models
}
displayStats(stats, args.tools, modelLimit)
}),
})
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
const stats = await aggregateSessionStats(args.days, args.project)
let modelLimit: number | undefined
if (args.models === true) {
modelLimit = Infinity
} else if (typeof args.models === "number") {
modelLimit = args.models
}
displayStats(stats, args.tools, modelLimit)
})
},
})
const getAllSessions = Effect.sync(() =>
Database.use((db) => db.select().from(SessionTable).all()).map((row) => Session.fromRow(row)),
)
async function getCurrentProject(): Promise<Project.Info> {
return Instance.project
}
const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* (
days?: number,
projectFilter?: string,
currentProject?: Project.Info,
) {
const svc = yield* Session.Service
const sessions = yield* getAllSessions
async function getAllSessions(): Promise<Session.Info[]> {
const rows = Database.use((db) => db.select().from(SessionTable).all())
return rows.map((row) => Session.fromRow(row))
}
export async function aggregateSessionStats(days?: number, projectFilter?: string): Promise<SessionStats> {
const sessions = await getAllSessions()
const MS_IN_DAY = 24 * 60 * 60 * 1000
const cutoffTime = (() => {
@@ -112,7 +117,7 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* (
if (projectFilter !== undefined) {
if (projectFilter === "") {
if (!currentProject) throw new Error("currentProject required when projectFilter is empty string")
const currentProject = await getCurrentProject()
filteredSessions = filteredSessions.filter((session) => session.projectID === currentProject.id)
} else {
filteredSessions = filteredSessions.filter((session) => session.projectID === projectFilter)
@@ -158,111 +163,122 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* (
const sessionTotalTokens: number[] = []
const results = yield* Effect.forEach(
filteredSessions,
(session) =>
Effect.gen(function* () {
const messages = yield* svc.messages({ sessionID: session.id })
const BATCH_SIZE = 20
for (let i = 0; i < filteredSessions.length; i += BATCH_SIZE) {
const batch = filteredSessions.slice(i, i + BATCH_SIZE)
let sessionCost = 0
let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
let sessionToolUsage: Record<string, number> = {}
let sessionModelUsage: Record<
string,
{
messages: number
tokens: { input: number; output: number; cache: { read: number; write: number } }
cost: number
}
> = {}
const batchPromises = batch.map(async (session) => {
const messages = await AppRuntime.runPromise(
Session.Service.use((svc) => svc.messages({ sessionID: session.id })),
)
for (const message of messages) {
if (message.info.role === "assistant") {
sessionCost += message.info.cost || 0
const modelKey = `${message.info.providerID}/${message.info.modelID}`
if (!sessionModelUsage[modelKey]) {
sessionModelUsage[modelKey] = {
messages: 0,
tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } },
cost: 0,
}
}
sessionModelUsage[modelKey].messages++
sessionModelUsage[modelKey].cost += message.info.cost || 0
if (message.info.tokens) {
sessionTokens.input += message.info.tokens.input || 0
sessionTokens.output += message.info.tokens.output || 0
sessionTokens.reasoning += message.info.tokens.reasoning || 0
sessionTokens.cache.read += message.info.tokens.cache?.read || 0
sessionTokens.cache.write += message.info.tokens.cache?.write || 0
sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0
sessionModelUsage[modelKey].tokens.output +=
(message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0)
sessionModelUsage[modelKey].tokens.cache.read += message.info.tokens.cache?.read || 0
sessionModelUsage[modelKey].tokens.cache.write += message.info.tokens.cache?.write || 0
let sessionCost = 0
let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
let sessionToolUsage: Record<string, number> = {}
let sessionModelUsage: Record<
string,
{
messages: number
tokens: {
input: number
output: number
cache: {
read: number
write: number
}
}
cost: number
}
> = {}
for (const part of message.parts) {
if (part.type === "tool" && part.tool) {
sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1
for (const message of messages) {
if (message.info.role === "assistant") {
sessionCost += message.info.cost || 0
const modelKey = `${message.info.providerID}/${message.info.modelID}`
if (!sessionModelUsage[modelKey]) {
sessionModelUsage[modelKey] = {
messages: 0,
tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } },
cost: 0,
}
}
sessionModelUsage[modelKey].messages++
sessionModelUsage[modelKey].cost += message.info.cost || 0
if (message.info.tokens) {
sessionTokens.input += message.info.tokens.input || 0
sessionTokens.output += message.info.tokens.output || 0
sessionTokens.reasoning += message.info.tokens.reasoning || 0
sessionTokens.cache.read += message.info.tokens.cache?.read || 0
sessionTokens.cache.write += message.info.tokens.cache?.write || 0
sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0
sessionModelUsage[modelKey].tokens.output +=
(message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0)
sessionModelUsage[modelKey].tokens.cache.read += message.info.tokens.cache?.read || 0
sessionModelUsage[modelKey].tokens.cache.write += message.info.tokens.cache?.write || 0
}
}
return {
messageCount: messages.length,
sessionCost,
sessionTokens,
sessionTotalTokens:
sessionTokens.input +
sessionTokens.output +
sessionTokens.reasoning +
sessionTokens.cache.read +
sessionTokens.cache.write,
sessionToolUsage,
sessionModelUsage,
earliestTime: cutoffTime > 0 ? session.time.updated : session.time.created,
latestTime: session.time.updated,
}
}),
{ concurrency: 20 },
)
for (const result of results) {
earliestTime = Math.min(earliestTime, result.earliestTime)
latestTime = Math.max(latestTime, result.latestTime)
sessionTotalTokens.push(result.sessionTotalTokens)
stats.totalMessages += result.messageCount
stats.totalCost += result.sessionCost
stats.totalTokens.input += result.sessionTokens.input
stats.totalTokens.output += result.sessionTokens.output
stats.totalTokens.reasoning += result.sessionTokens.reasoning
stats.totalTokens.cache.read += result.sessionTokens.cache.read
stats.totalTokens.cache.write += result.sessionTokens.cache.write
for (const [tool, count] of Object.entries(result.sessionToolUsage)) {
stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count
}
for (const [model, usage] of Object.entries(result.sessionModelUsage)) {
if (!stats.modelUsage[model]) {
stats.modelUsage[model] = {
messages: 0,
tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } },
cost: 0,
for (const part of message.parts) {
if (part.type === "tool" && part.tool) {
sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1
}
}
}
stats.modelUsage[model].messages += usage.messages
stats.modelUsage[model].tokens.input += usage.tokens.input
stats.modelUsage[model].tokens.output += usage.tokens.output
stats.modelUsage[model].tokens.cache.read += usage.tokens.cache.read
stats.modelUsage[model].tokens.cache.write += usage.tokens.cache.write
stats.modelUsage[model].cost += usage.cost
return {
messageCount: messages.length,
sessionCost,
sessionTokens,
sessionTotalTokens:
sessionTokens.input +
sessionTokens.output +
sessionTokens.reasoning +
sessionTokens.cache.read +
sessionTokens.cache.write,
sessionToolUsage,
sessionModelUsage,
earliestTime: cutoffTime > 0 ? session.time.updated : session.time.created,
latestTime: session.time.updated,
}
})
const batchResults = await Promise.all(batchPromises)
for (const result of batchResults) {
earliestTime = Math.min(earliestTime, result.earliestTime)
latestTime = Math.max(latestTime, result.latestTime)
sessionTotalTokens.push(result.sessionTotalTokens)
stats.totalMessages += result.messageCount
stats.totalCost += result.sessionCost
stats.totalTokens.input += result.sessionTokens.input
stats.totalTokens.output += result.sessionTokens.output
stats.totalTokens.reasoning += result.sessionTokens.reasoning
stats.totalTokens.cache.read += result.sessionTokens.cache.read
stats.totalTokens.cache.write += result.sessionTokens.cache.write
for (const [tool, count] of Object.entries(result.sessionToolUsage)) {
stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count
}
for (const [model, usage] of Object.entries(result.sessionModelUsage)) {
if (!stats.modelUsage[model]) {
stats.modelUsage[model] = {
messages: 0,
tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } },
cost: 0,
}
}
stats.modelUsage[model].messages += usage.messages
stats.modelUsage[model].tokens.input += usage.tokens.input
stats.modelUsage[model].tokens.output += usage.tokens.output
stats.modelUsage[model].tokens.cache.read += usage.tokens.cache.read
stats.modelUsage[model].tokens.cache.write += usage.tokens.cache.write
stats.modelUsage[model].cost += usage.cost
}
}
}
@@ -291,7 +307,7 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* (
: sessionTotalTokens[mid]
return stats
})
}
export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit?: number) {
const width = 56

View File

@@ -28,7 +28,6 @@ import { useEvent } from "@tui/context/event"
import { SDKProvider, useSDK } from "@tui/context/sdk"
import { StartupLoading } from "@tui/component/startup-loading"
import { SyncProvider, useSync } from "@tui/context/sync"
import { SyncProviderV2 } from "@tui/context/sync-v2"
import { LocalProvider, useLocal } from "@tui/context/local"
import { DialogModel } from "@tui/component/dialog-model"
import { useConnected } from "@tui/component/use-connected"
@@ -169,29 +168,27 @@ export function tui(input: {
>
<ProjectProvider>
<SyncProvider>
<SyncProviderV2>
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<App onSnapshot={input.onSnapshot} />
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProviderV2>
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<App onSnapshot={input.onSnapshot} />
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
</ProjectProvider>
</SDKProvider>
@@ -779,15 +776,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
dialog.clear()
},
},
{
title: kv.get("clear_prompt_save_history", false) ? "Don't include cleared prompts in history" : "Include cleared prompts in history",
value: "app.toggle.clear_prompt_history",
category: "System",
onSelect: (dialog) => {
kv.set("clear_prompt_save_history", !kv.get("clear_prompt_save_history", false))
dialog.clear()
},
},
])
event.on(TuiEvent.CommandExecute.type, (evt) => {

View File

@@ -5,7 +5,6 @@ import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { errorMessage } from "@/util/error"
import { validateSession } from "./validate-session"
import { ServerAuth } from "@/server/auth"
export const AttachCommand = cmd({
command: "attach <url>",
@@ -39,11 +38,6 @@ export const AttachCommand = cmd({
alias: ["p"],
type: "string",
describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
})
.option("username", {
alias: ["u"],
type: "string",
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
}),
handler: async (args) => {
const unguard = win32InstallCtrlCGuard()
@@ -66,7 +60,12 @@ export const AttachCommand = cmd({
return args.dir
}
})()
const headers = ServerAuth.headers({ password: args.password, username: args.username })
const headers = (() => {
const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
if (!password) return undefined
const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
return { Authorization: auth }
})()
const config = await TuiConfig.get()
try {

View File

@@ -82,7 +82,6 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
return store.history.at(store.index)
},
append(item: PromptInfo) {
if (store.history.at(-1)?.input === item.input) return
const entry = structuredClone(unwrap(item))
let trimmed = false
setStore(

View File

@@ -173,7 +173,6 @@ export function Prompt(props: PromptProps) {
const [editorContextHover, setEditorContextHover] = createSignal(false)
let lastSubmittedEditorSelectionKey: string | undefined
const [auto, setAuto] = createSignal<AutocompleteRef>()
const [autoaccept, setAutoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right))
@@ -297,17 +296,6 @@ export function Prompt(props: PromptProps) {
command.register(() => {
return [
{
title: autoaccept() === "none" ? "Enable autoedit" : "Disable autoedit",
value: "permission.auto_accept.toggle",
search: "toggle permissions",
keybind: "permission_auto_accept_toggle",
category: "Agent",
onSelect: (dialog) => {
setAutoaccept(() => (autoaccept() === "none" ? "edit" : "none"))
dialog.clear()
},
},
{
title: "Clear prompt",
value: "prompt.clear",
@@ -762,18 +750,9 @@ export function Prompt(props: PromptProps) {
return false
}
const variant = local.model.variant.current()
let sessionID = props.sessionID
if (sessionID == null) {
const res = await sdk.client.session.create({
workspace: props.workspaceID,
agent: agent.name,
model: {
providerID: selectedModel.providerID,
id: selectedModel.modelID,
variant,
},
})
const res = await sdk.client.session.create({ workspace: props.workspaceID })
if (res.error) {
console.log("Creating a session failed:", res.error)
@@ -813,6 +792,7 @@ export function Prompt(props: PromptProps) {
// Capture mode before it gets reset
const currentMode = store.mode
const variant = local.model.variant.current()
const editorSelection = editorContext()
const currentEditorSelectionKey = editorSelectionKey(editorSelection)
const editorParts =
@@ -1136,12 +1116,6 @@ export function Prompt(props: PromptProps) {
// If no image, let the default paste behavior continue
}
if (keybind.match("input_clear", e) && store.prompt.input !== "") {
if (kv.get("clear_prompt_save_history", false)) {
history.append({
...store.prompt,
mode: store.mode,
})
}
input.clear()
input.extmarks.clear()
setStore("prompt", {
@@ -1336,11 +1310,6 @@ export function Prompt(props: PromptProps) {
</box>
<Show when={hasRightContent()}>
<box flexDirection="row" gap={1} alignItems="center">
<Show when={autoaccept() === "edit"}>
<text>
<span style={{ fg: theme.warning }}>autoedit</span>
</text>
</Show>
{props.right}
</box>
</Show>

View File

@@ -68,73 +68,29 @@ function normalize(raw: Record<string, unknown>) {
}
}
async function resolvePlugins(config: Info, configFilepath: string) {
if (!config.plugin) return config
for (let i = 0; i < config.plugin.length; i++) {
config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath)
}
return config
}
async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
const data = await loadFile(file)
acc.result = mergeDeep(acc.result, data)
if (!data.plugin?.length) return
const scope = pluginScope(file, ctx)
const plugins = ConfigPlugin.deduplicatePluginOrigins([
...(acc.result.plugin_origins ?? []),
...data.plugin.map((spec) => ({ spec, scope, source: file })),
])
acc.result.plugin = plugins.map((item) => item.spec)
acc.result.plugin_origins = plugins
}
const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) {
const afs = yield* AppFileSystem.Service
const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect<Info> =>
Effect.gen(function* () {
const plugins = config.plugin
if (!plugins) return config
for (let i = 0; i < plugins.length; i++) {
plugins[i] = yield* Effect.promise(() => ConfigPlugin.resolvePluginSpec(plugins[i], configFilepath))
}
return config
})
const load = (text: string, configFilepath: string): Effect.Effect<Info> =>
Effect.gen(function* () {
const expanded = yield* Effect.promise(() =>
ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" }),
)
const data = ConfigParse.jsonc(expanded, configFilepath)
if (!isRecord(data)) return {} as Info
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
// (mirroring the old opencode.json shape) still get their settings applied.
const validated = ConfigParse.schema(Info, normalize(data), configFilepath)
return yield* resolvePlugins(validated, configFilepath)
}).pipe(
// catchCause (not tapErrorCause + orElseSucceed) because ConfigParse.jsonc/.schema
// can sync-throw — those become defects, which orElseSucceed wouldn't catch.
Effect.catchCause((cause) =>
Effect.sync(() => {
log.warn("invalid tui config", { path: configFilepath, cause })
return {} as Info
}),
),
)
const loadFile = (filepath: string): Effect.Effect<Info> =>
Effect.gen(function* () {
// Silent-swallow non-NotFound read errors (perms, EISDIR, IO) → log + skip.
// Matches how parse/schema/plugin failures in load() are handled — every
// broken-config path degrades gracefully rather than crashing TUI startup.
const text = yield* afs.readFileStringSafe(filepath).pipe(
Effect.catchCause((cause) =>
Effect.sync(() => {
log.warn("failed to read tui config", { path: filepath, cause })
return undefined
}),
),
)
if (!text) return {} as Info
return yield* load(text, filepath)
})
const mergeFile = (acc: Acc, file: string) =>
Effect.gen(function* () {
const data = yield* loadFile(file)
acc.result = mergeDeep(acc.result, data)
if (!data.plugin?.length) return
const scope = pluginScope(file, ctx)
const plugins = ConfigPlugin.deduplicatePluginOrigins([
...(acc.result.plugin_origins ?? []),
...data.plugin.map((spec) => ({ spec, scope, source: file })),
])
acc.result.plugin = plugins.map((item) => item.spec)
acc.result.plugin_origins = plugins
})
// Every config dir we may read from: global config dir, any `.opencode`
// folders between cwd and home, and OPENCODE_CONFIG_DIR.
const directories = yield* ConfigPaths.directories(ctx.directory)
@@ -148,19 +104,19 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
// 1. Global tui config (lowest precedence).
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
yield* mergeFile(acc, file)
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
}
// 2. Explicit OPENCODE_TUI_CONFIG override, if set.
if (Flag.OPENCODE_TUI_CONFIG) {
const configFile = Flag.OPENCODE_TUI_CONFIG
yield* mergeFile(acc, configFile)
yield* Effect.promise(() => mergeFile(acc, configFile, ctx)).pipe(Effect.orDie)
log.debug("loaded custom tui config", { path: configFile })
}
// 3. Project tui files, applied root-first so the closest file wins.
for (const file of projectFiles) {
yield* mergeFile(acc, file)
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
}
// 4. `.opencode` directories (and OPENCODE_CONFIG_DIR) discovered while
@@ -171,7 +127,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
for (const dir of dirs) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
yield* mergeFile(acc, file)
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
}
}
@@ -237,3 +193,28 @@ export async function get() {
return runPromise((svc) => svc.get())
}
async function loadFile(filepath: string): Promise<Info> {
const text = await ConfigPaths.readFile(filepath)
if (!text) return {}
return load(text, filepath).catch((error) => {
log.warn("failed to load tui config", { path: filepath, error })
return {}
})
}
async function load(text: string, configFilepath: string): Promise<Info> {
return ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" })
.then((expanded) => ConfigParse.jsonc(expanded, configFilepath))
.then((data) => {
if (!isRecord(data)) return {}
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
// (mirroring the old opencode.json shape) still get their settings applied.
return ConfigParse.schema(Info, normalize(data), configFilepath)
})
.then((data) => resolvePlugins(data, configFilepath))
.catch((error) => {
log.warn("invalid tui config", { path: configFilepath, error })
return {}
})
}

View File

@@ -1,298 +0,0 @@
import { useEvent } from "@tui/context/event"
import type {
SessionMessage,
SessionMessageAssistant,
SessionMessageAssistantReasoning,
SessionMessageAssistantText,
SessionMessageAssistantTool,
} from "@opencode-ai/sdk/v2"
import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
function activeAssistant(messages: SessionMessage[]) {
const index = messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed)
if (index < 0) return
const assistant = messages[index]
return assistant?.type === "assistant" ? assistant : undefined
}
function activeCompaction(messages: SessionMessage[]) {
const index = messages.findLastIndex((message) => message.type === "compaction")
if (index < 0) return
const compaction = messages[index]
return compaction?.type === "compaction" ? compaction : undefined
}
function activeShell(messages: SessionMessage[], callID: string) {
const index = messages.findLastIndex((message) => message.type === "shell" && message.callID === callID)
if (index < 0) return
const shell = messages[index]
return shell?.type === "shell" ? shell : undefined
}
function latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
return assistant?.content.findLast(
(item): item is SessionMessageAssistantTool => item.type === "tool" && (callID === undefined || item.id === callID),
)
}
function latestText(assistant: SessionMessageAssistant | undefined) {
return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text")
}
function latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) {
return assistant?.content.findLast(
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && item.id === reasoningID,
)
}
export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext({
name: "SyncV2",
init: () => {
const [store, setStore] = createStore<{
messages: {
[sessionID: string]: SessionMessage[]
}
}>({
messages: {},
})
const event = useEvent()
const sdk = useSDK()
function update(sessionID: string, fn: (messages: SessionMessage[]) => void) {
setStore(
"messages",
produce((draft) => {
fn((draft[sessionID] ??= []))
}),
)
}
event.subscribe((event) => {
switch (event.type) {
case "session.next.prompted": {
update(event.properties.sessionID, (draft) => {
draft.push({
id: event.id,
type: "user",
text: event.properties.prompt.text,
files: event.properties.prompt.files,
agents: event.properties.prompt.agents,
time: { created: event.properties.timestamp },
})
})
break
}
case "session.next.synthetic":
update(event.properties.sessionID, (draft) => {
draft.push({
id: event.id,
type: "synthetic",
sessionID: event.properties.sessionID,
text: event.properties.text,
time: { created: event.properties.timestamp },
})
})
break
case "session.next.shell.started":
update(event.properties.sessionID, (draft) => {
draft.push({
id: event.id,
type: "shell",
callID: event.properties.callID,
command: event.properties.command,
output: "",
time: { created: event.properties.timestamp },
})
})
break
case "session.next.shell.ended":
update(event.properties.sessionID, (draft) => {
const match = activeShell(draft, event.properties.callID)
if (!match) return
match.output = event.properties.output
match.time.completed = event.properties.timestamp
})
break
case "session.next.step.started":
update(event.properties.sessionID, (draft) => {
const currentAssistant = activeAssistant(draft)
if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
draft.push({
id: event.id,
type: "assistant",
agent: event.properties.agent,
model: event.properties.model,
content: [],
snapshot: event.properties.snapshot ? { start: event.properties.snapshot } : undefined,
time: { created: event.properties.timestamp },
})
})
break
case "session.next.step.ended":
update(event.properties.sessionID, (draft) => {
const currentAssistant = activeAssistant(draft)
if (!currentAssistant) return
currentAssistant.time.completed = event.properties.timestamp
currentAssistant.finish = event.properties.finish
currentAssistant.cost = event.properties.cost
currentAssistant.tokens = event.properties.tokens
if (event.properties.snapshot)
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot }
})
break
case "session.next.text.started":
update(event.properties.sessionID, (draft) => {
activeAssistant(draft)?.content.push({ type: "text", text: "" })
})
break
case "session.next.text.delta":
update(event.properties.sessionID, (draft) => {
const match = latestText(activeAssistant(draft))
if (match) match.text += event.properties.delta
})
break
case "session.next.text.ended":
update(event.properties.sessionID, (draft) => {
const match = latestText(activeAssistant(draft))
if (match) match.text = event.properties.text
})
break
case "session.next.tool.input.started":
update(event.properties.sessionID, (draft) => {
activeAssistant(draft)?.content.push({
type: "tool",
id: event.properties.callID,
name: event.properties.name,
time: { created: event.properties.timestamp },
state: { status: "pending", input: "" },
})
})
break
case "session.next.tool.input.delta":
update(event.properties.sessionID, (draft) => {
const match = latestTool(activeAssistant(draft), event.properties.callID)
if (match?.state.status === "pending") match.state.input += event.properties.delta
})
break
case "session.next.tool.input.ended":
break
case "session.next.tool.called":
update(event.properties.sessionID, (draft) => {
const match = latestTool(activeAssistant(draft), event.properties.callID)
if (!match) return
match.time.ran = event.properties.timestamp
match.provider = event.properties.provider
match.state = { status: "running", input: event.properties.input, structured: {}, content: [] }
})
break
case "session.next.tool.progress":
update(event.properties.sessionID, (draft) => {
const match = latestTool(activeAssistant(draft), event.properties.callID)
if (match?.state.status !== "running") return
match.state.structured = event.properties.structured
match.state.content = [...event.properties.content]
})
break
case "session.next.tool.success":
update(event.properties.sessionID, (draft) => {
const match = latestTool(activeAssistant(draft), event.properties.callID)
if (match?.state.status !== "running") return
match.state = {
status: "completed",
input: match.state.input,
structured: event.properties.structured,
content: [...event.properties.content],
}
match.provider = event.properties.provider
match.time.completed = event.properties.timestamp
})
break
case "session.next.tool.error":
update(event.properties.sessionID, (draft) => {
const match = latestTool(activeAssistant(draft), event.properties.callID)
if (match?.state.status !== "running") return
match.state = {
status: "error",
error: event.properties.error,
input: match.state.input,
structured: match.state.structured,
content: match.state.content,
}
match.provider = event.properties.provider
match.time.completed = event.properties.timestamp
})
break
case "session.next.reasoning.started":
update(event.properties.sessionID, (draft) => {
activeAssistant(draft)?.content.push({
type: "reasoning",
id: event.properties.reasoningID,
text: "",
})
})
break
case "session.next.reasoning.delta":
update(event.properties.sessionID, (draft) => {
const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID)
if (match) match.text += event.properties.delta
})
break
case "session.next.reasoning.ended":
update(event.properties.sessionID, (draft) => {
const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID)
if (match) match.text = event.properties.text
})
break
case "session.next.retried":
break
case "session.next.compaction.started":
update(event.properties.sessionID, (draft) => {
draft.push({
id: event.id,
type: "compaction",
reason: event.properties.reason,
summary: "",
time: { created: event.properties.timestamp },
})
})
break
case "session.next.compaction.delta":
update(event.properties.sessionID, (draft) => {
const match = activeCompaction(draft)
if (match) match.summary += event.properties.text
})
break
case "session.next.compaction.ended":
update(event.properties.sessionID, (draft) => {
const match = activeCompaction(draft)
if (!match) return
match.summary = event.properties.text
match.include = event.properties.include
})
break
}
})
const result = {
data: store,
session: {
message: {
async sync(sessionID: string) {
const response = await sdk.client.v2.session.messages({ sessionID })
setStore("messages", sessionID, reconcile(response.data?.items ?? []))
},
fromSession(sessionID: string) {
const messages = store.messages[sessionID]
if (!messages) return []
return messages
},
},
},
}
return result
},
})

View File

@@ -110,7 +110,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const project = useProject()
const sdk = useSDK()
const kv = useKV()
const [autoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
const fullSyncedSessions = new Set<string>()
let syncedWorkspace = project.workspace.current()
@@ -153,13 +152,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
case "permission.asked": {
const request = event.properties
if (autoaccept() === "edit" && request.permission === "edit") {
sdk.client.permission.reply({
reply: "once",
requestID: request.id,
})
break
}
const requests = store.permission[request.sessionID]
if (!requests) {
setStore("permission", request.sessionID, [request])

View File

@@ -7,9 +7,7 @@ import SidebarTodo from "../feature-plugins/sidebar/todo"
import SidebarFiles from "../feature-plugins/sidebar/files"
import SidebarFooter from "../feature-plugins/sidebar/footer"
import PluginManager from "../feature-plugins/system/plugins"
import SessionV2Debug from "../feature-plugins/system/session-v2"
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { Flag } from "@opencode-ai/core/flag/flag"
export type InternalTuiPlugin = TuiPluginModule & {
id: string
@@ -26,5 +24,4 @@ export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [
SidebarFiles,
SidebarFooter,
PluginManager,
...(Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM ? [SessionV2Debug] : []),
]

View File

@@ -16,7 +16,7 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import * as Log from "@opencode-ai/core/util/log"
import { errorData, errorMessage } from "@/util/error"
import { isRecord } from "@/util/record"
import { WithInstance } from "@/project/with-instance"
import { Instance } from "@/project/instance"
import {
readPackageThemes,
readPluginId,
@@ -790,7 +790,7 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
state.pending.delete(spec)
return true
}
const ready = await WithInstance.provide({
const ready = await Instance.provide({
directory: state.directory,
fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()),
}).catch((error) => {
@@ -986,7 +986,7 @@ async function load(input: { api: Api; config: TuiConfig.Info }) {
}
runtime = next
try {
await WithInstance.provide({
await Instance.provide({
directory: cwd,
fn: async () => {
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])

View File

@@ -37,8 +37,7 @@ import { Locale } from "@/util/locale"
import type { Tool } from "@/tool/tool"
import type { ReadTool } from "@/tool/read"
import type { WriteTool } from "@/tool/write"
import { ShellTool } from "@/tool/shell"
import { ShellID } from "@/tool/shell/id"
import { BashTool } from "@/tool/bash"
import type { GlobTool } from "@/tool/glob"
import { TodoWriteTool } from "@/tool/todo"
import type { GrepTool } from "@/tool/grep"
@@ -1553,8 +1552,8 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
return (
<Show when={!shouldHide()}>
<Switch>
<Match when={props.part.tool === ShellID.ToolID}>
<Shell {...toolprops} />
<Match when={props.part.tool === "bash"}>
<Bash {...toolprops} />
</Match>
<Match when={props.part.tool === "glob"}>
<Glob {...toolprops} />
@@ -1785,7 +1784,7 @@ function BlockTool(props: {
)
}
function Shell(props: ToolProps<typeof ShellTool>) {
function Bash(props: ToolProps<typeof BashTool>) {
const { theme } = useTheme()
const sync = useSync()
const isRunning = createMemo(() => props.part.state.status === "running")

View File

@@ -15,7 +15,6 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale"
import { Global } from "@opencode-ai/core/global"
import { ShellID } from "@/tool/shell/id"
import { useDialog } from "../../ui/dialog"
import { getScrollAcceleration } from "../../util/scroll"
import { useTuiConfig } from "../../context/tui-config"
@@ -288,7 +287,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
}
}
if (permission === ShellID.ToolID) {
if (permission === "bash") {
const title =
typeof data.description === "string" && data.description ? data.description : "Shell command"
const command = typeof data.command === "string" ? data.command : ""

View File

@@ -8,7 +8,6 @@ import { UI } from "@/cli/ui"
import * as Log from "@opencode-ai/core/util/log"
import { errorMessage } from "@/util/error"
import { withTimeout } from "@/util/timeout"
import { WithInstance } from "@/project/with-instance"
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
import { Filesystem } from "@/util/filesystem"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
@@ -191,11 +190,7 @@ export const TuiThreadCommand = cmd({
const prompt = await input(args.prompt)
const config = await TuiConfig.get()
const network = await WithInstance.provide({
directory: cwd,
fn: () => resolveNetworkOptionsNoConfig(args),
})
const network = resolveNetworkOptionsNoConfig(args)
const external =
process.argv.includes("--port") ||
process.argv.includes("--hostname") ||

View File

@@ -37,7 +37,6 @@ export interface DialogSelectOption<T = any> {
title: string
value: T
description?: string
search?: string
footer?: JSX.Element | string
category?: string
categoryView?: JSX.Element
@@ -94,8 +93,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
// users typically search by the item name, and not its category.
const result = fuzzysort
.go(needle, options, {
keys: ["title", "category", "search"],
scoreFn: (r) => r[0].score * 2 + r[1].score + r[2].score,
keys: ["title", "category"],
scoreFn: (r) => r[0].score * 2 + r[1].score,
})
.map((x) => x.obj)

View File

@@ -1,19 +1,17 @@
import { Installation } from "@/installation"
import { Server } from "@/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { InstanceRuntime } from "@/project/instance-runtime"
import { WithInstance } from "@/project/with-instance"
import { Instance } from "@/project/instance"
import { InstanceStore } from "@/project/instance-store"
import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
import { GlobalBus } from "@/bus/global"
import { ServerAuth } from "@/server/auth"
import { Flag } from "@opencode-ai/core/flag/flag"
import { writeHeapSnapshot } from "node:v8"
import { Heap } from "@/cli/heap"
import { AppRuntime } from "@/effect/app-runtime"
import { AppRuntime, getBootstrapRunEffect } from "@/effect/app-runtime"
import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process"
import { Effect } from "effect"
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
ensureProcessMetadata("worker")
@@ -50,7 +48,7 @@ let server: Awaited<ReturnType<typeof Server.listen>> | undefined
export const rpc = {
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
const headers = { ...input.headers }
const auth = ServerAuth.header()
const auth = getAuthorizationHeader()
if (auth && !headers["authorization"] && !headers["Authorization"]) {
headers["Authorization"] = auth
}
@@ -77,28 +75,30 @@ export const rpc = {
return { url: server.url.toString() }
},
async checkUpgrade(input: { directory: string }) {
await WithInstance.provide({
await Instance.provide({
directory: input.directory,
init: await getBootstrapRunEffect(),
fn: async () => {
await upgrade().catch(() => {})
},
})
},
async reload() {
await AppRuntime.runPromise(
Effect.gen(function* () {
const cfg = yield* Config.Service
yield* cfg.invalidate()
yield* disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true })
}),
)
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.invalidate(true)))
},
async shutdown() {
Log.Default.info("worker shutting down")
await InstanceRuntime.disposeAllInstances()
await InstanceStore.disposeAllInstances()
if (server) await server.stop(true)
},
}
Rpc.listen(rpc)
function getAuthorizationHeader(): string | undefined {
const password = Flag.OPENCODE_SERVER_PASSWORD
if (!password) return undefined
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
return `Basic ${btoa(`${username}:${password}`)}`
}

View File

@@ -1,7 +1,6 @@
import { Effect } from "effect"
import { Server } from "../../server/server"
import { UI } from "../ui"
import { effectCmd } from "../effect-cmd"
import { cmd } from "./cmd"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { Flag } from "@opencode-ai/core/flag/flag"
import open from "open"
@@ -29,19 +28,16 @@ function getNetworkIPs() {
return results
}
export const WebCommand = effectCmd({
export const WebCommand = cmd({
command: "web",
builder: (yargs) => withNetworkOptions(yargs),
describe: "start opencode server and open web interface",
// Server loads instances per-request via x-opencode-directory header — no
// ambient project InstanceContext needed at startup.
instance: false,
handler: Effect.fn("Cli.web")(function* (args) {
handler: async (args) => {
if (!Flag.OPENCODE_SERVER_PASSWORD) {
UI.println(UI.Style.TEXT_WARNING_BOLD + "! OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = yield* Effect.promise(() => resolveNetworkOptions(args))
const server = yield* Effect.promise(() => Server.listen(opts))
const opts = await resolveNetworkOptions(args)
const server = await Server.listen(opts)
UI.empty()
UI.println(UI.logo(" "))
UI.empty()
@@ -79,6 +75,7 @@ export const WebCommand = effectCmd({
open(displayUrl).catch(() => {})
}
yield* Effect.never
}),
await new Promise(() => {})
await server.stop()
},
})

View File

@@ -2,9 +2,7 @@ import type { Argv } from "yargs"
import { Effect, Schema } from "effect"
import { AppRuntime, type AppServices } from "@/effect/app-runtime"
import { InstanceStore } from "@/project/instance-store"
import { InstanceRef } from "@/effect/instance-ref"
import { Instance } from "@/project/instance"
import { cmd, type WithDoubleDash } from "./cmd/cmd"
import { cmd } from "./cmd/cmd"
/**
* User-visible command failure. Throw via `fail("...")` from an effectCmd handler
@@ -19,47 +17,10 @@ export class CliError extends Schema.TaggedErrorClass<CliError>()("CliError", {
export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError({ message, exitCode }))
interface EffectCmdOpts<Args, A> {
command: string | readonly string[]
aliases?: string | readonly string[]
describe: string | false
builder?: (yargs: Argv) => Argv<Args>
/**
* Whether the command needs a project InstanceContext. Defaults to true.
*
* `true` (default): wraps the handler in `InstanceStore.Service.provide({directory})`
* so `InstanceRef` resolves to a loaded `InstanceContext`. Auto-disposes via
* `Effect.ensuring(store.dispose(ctx))` on every Exit (matches the legacy
* `bootstrap()` finally-disposal). Runs InstanceBootstrap (config + plugin
* init + LSP/File/etc forks) eagerly.
*
* `false`: skip the instance entirely. Saves the InstanceBootstrap work and
* suppresses the `server.instance.disposed` IPC event. The handler runs
* directly under AppRuntime — it can yield any `AppServices` but must not
* yield `InstanceRef` (it'd be undefined, causing a defect).
*
* Function form: `(args) => boolean` decides per-invocation. Useful for
* commands like `run --attach <url>` where one flag flips between local
* (needs instance) and remote (doesn't).
*
* Use `false` for commands that don't read project state (e.g. `models`,
* `serve`, `web`, `account`, `db`, `upgrade`).
*/
instance?: boolean | ((args: Args) => boolean)
/** Defaults to process.cwd(). Override for commands that take a directory positional. */
directory?: (args: Args) => string
handler: (args: WithDoubleDash<Args>) => Effect.Effect<A, CliError, AppServices | InstanceStore.Service>
}
/**
* Effect-native CLI command builder. Wraps yargs `cmd()` so the handler body is
* an `Effect` with `InstanceRef` provided and any `AppServices` yieldable.
*
* The handler is wrapped in `Effect.ensuring(store.dispose(ctx))` so the loaded
* InstanceContext is disposed (runDisposers + IPC `server.instance.disposed`)
* on every Exit — success, typed failure, defect, or interruption. Matches the
* legacy `bootstrap()` finally-disposal semantics without per-handler boilerplate.
*
* Errors propagate to the existing top-level handler in `src/index.ts`; use
* `fail("...")` for user-visible domain failures (clean exit, formatted message).
*
@@ -68,36 +29,22 @@ interface EffectCmdOpts<Args, A> {
* `effectCmd`, swapping the underlying `cmd()` factory for effect/cli's
* `Command.make(...)` won't touch any handler bodies.
*/
export const effectCmd = <Args, A>(opts: EffectCmdOpts<Args, A>) =>
export const effectCmd = <Args, A>(opts: {
command: string | readonly string[]
describe: string | false
builder?: (yargs: Argv) => Argv<Args>
/** Defaults to process.cwd(). Override for commands that take a directory positional. */
directory?: (args: Args) => string
handler: (args: Args) => Effect.Effect<A, CliError, AppServices | InstanceStore.Service>
}) =>
cmd<{}, Args>({
command: opts.command,
aliases: opts.aliases,
describe: opts.describe,
builder: opts.builder as never,
async handler(rawArgs) {
// yargs typing wraps Args in ArgumentsCamelCase<WithDoubleDash<...>>; cast at the boundary.
const args = rawArgs as unknown as WithDoubleDash<Args>
const useInstance = typeof opts.instance === "function" ? opts.instance(args) : opts.instance !== false
if (!useInstance) {
await AppRuntime.runPromise(opts.handler(args))
return
}
const args = rawArgs as unknown as Args
const directory = opts.directory?.(args) ?? process.cwd()
// Two-phase: load ctx, then run body inside Instance.current ALS.
// Effect's InstanceRef is provided via fiber context, but that context is
// lost across `await` inside `Effect.promise(async () => ...)` callbacks
// — when handlers re-enter Effect via `AppRuntime.runPromise(svc.method())`
// there, attach() falls back to Instance.current ALS, which Node preserves
// across awaits. Matches the pre-effectCmd `bootstrap()` behavior.
const { store, ctx } = await AppRuntime.runPromise(
InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))),
)
try {
await Instance.restore(ctx, () =>
AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx))),
)
} finally {
await AppRuntime.runPromise(store.dispose(ctx))
}
await AppRuntime.runPromise(InstanceStore.Service.use((s) => s.provide({ directory }, opts.handler(args))))
},
})

View File

@@ -12,8 +12,11 @@ import { Auth } from "../auth"
import { Env } from "../env"
import { applyEdits, modify } from "jsonc-parser"
import { type InstanceContext } from "../project/instance"
import { InstanceStore } from "../project/instance-store"
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
import { existsSync } from "fs"
import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
import { Account } from "@/account/account"
import { isRecord } from "@/util/record"
import type { ConsoleState } from "./console-state"
@@ -192,14 +195,8 @@ export const Info = Schema.Struct({
]),
),
).annotate({ description: "MCP (Model Context Protocol) server configurations" }),
formatter: Schema.optional(ConfigFormatter.Info).annotate({
description:
"Enable or configure formatters. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides.",
}),
lsp: Schema.optional(ConfigLSP.Info).annotate({
description:
"Enable or configure LSP servers. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides.",
}),
formatter: Schema.optional(ConfigFormatter.Info),
lsp: Schema.optional(ConfigLSP.Info),
instructions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
description: "Additional instruction files or patterns to include",
}),
@@ -292,9 +289,9 @@ export interface Interface {
readonly get: () => Effect.Effect<Info>
readonly getGlobal: () => Effect.Effect<Info>
readonly getConsoleState: () => Effect.Effect<ConsoleState>
readonly update: (config: Info) => Effect.Effect<void>
readonly updateGlobal: (config: Info) => Effect.Effect<{ info: Info; changed: boolean }>
readonly invalidate: () => Effect.Effect<void>
readonly update: (config: Info, options?: { dispose?: boolean }) => Effect.Effect<void>
readonly updateGlobal: (config: Info) => Effect.Effect<Info>
readonly invalidate: (wait?: boolean) => Effect.Effect<void>
readonly directories: () => Effect.Effect<string[]>
readonly waitForDependencies: () => Effect.Effect<void>
}
@@ -355,7 +352,15 @@ export const layer = Layer.effect(
const env = yield* Env.Service
const npmSvc = yield* Npm.Service
const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie)
const readConfigFile = Effect.fnUntraced(function* (filepath: string) {
return yield* fs.readFileString(filepath).pipe(
Effect.catchIf(
(e) => e.reason._tag === "NotFound",
() => Effect.succeed(undefined),
),
Effect.orDie,
)
})
const loadConfig = Effect.fnUntraced(function* (
text: string,
@@ -725,17 +730,37 @@ export const layer = Layer.effect(
)
})
const update = Effect.fn("Config.update")(function* (config: Info) {
const update = Effect.fn("Config.update")(function* (config: Info, options?: { dispose?: boolean }) {
const dir = yield* InstanceState.directory
const file = path.join(dir, "config.json")
const existing = yield* loadFile(file)
yield* fs
.writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2))
.pipe(Effect.orDie)
if (options?.dispose !== false) {
// Fail loudly if no instance is bound — silently skipping would
// mask "config update without an active instance" bugs. The throw
// comes from `Instance.current` inside `InstanceState.context`.
const ctx = yield* InstanceState.context
yield* Effect.promise(() => InstanceStore.disposeInstance(ctx))
}
})
const invalidate = Effect.fn("Config.invalidate")(function* () {
const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
yield* invalidateGlobal
const task = InstanceStore.disposeAllInstances()
.catch(() => undefined)
.finally(() =>
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Event.Disposed.type,
properties: {},
},
}),
)
if (wait) yield* Effect.promise(() => task)
else void task
})
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
@@ -759,8 +784,9 @@ export const layer = Layer.effect(
if (changed) yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
}
// Only tear down running instances if the config actually changed.
if (changed) yield* invalidate()
return { info: next, changed }
return next
})
return Service.of({

View File

@@ -1,9 +1,11 @@
export * as ConfigPaths from "./paths"
import path from "path"
import { Filesystem } from "@/util/filesystem"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { unique } from "remeda"
import { JsonError } from "./error"
import * as Effect from "effect/Effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
@@ -43,3 +45,11 @@ export const directories = Effect.fn("ConfigPaths.directories")(function* (direc
export function fileInDirectory(dir: string, name: string) {
return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)]
}
/** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */
export async function readFile(filepath: string) {
return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT") return
throw new JsonError({ path: filepath }, { cause: err })
})
}

View File

@@ -1,4 +1,4 @@
import { Layer, ManagedRuntime } from "effect"
import { Effect, Layer, ManagedRuntime } from "effect"
import { attach } from "./run-service"
import * as Observability from "@opencode-ai/core/effect/observability"
@@ -40,44 +40,27 @@ import { Command } from "@/command"
import { Truncate } from "@/tool/truncate"
import { ToolRegistry } from "@/tool/registry"
import { Format } from "@/format"
import { InstanceLayer } from "@/project/instance-layer"
import { InstanceBootstrap } from "@/project/bootstrap"
import { InstanceStore } from "@/project/instance-store"
import { Project } from "@/project/project"
import { Vcs } from "@/project/vcs"
import { Workspace } from "@/control-plane/workspace"
import { Worktree } from "@/worktree"
import { Pty } from "@/pty"
import { Installation } from "@/installation"
import * as Effect from "effect/Effect"
import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session"
import { SyncEvent } from "@/sync"
import { Npm } from "@opencode-ai/core/npm"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
// Adjusts the default Config layer to ensure that plugins are always initialised before
// any other layers read the current config
const ConfigWithPluginPriority = Layer.effect(
Config.Service,
Effect.gen(function* () {
const config = yield* Config.Service
const plugin = yield* Plugin.Service
return {
...config,
get: () => Effect.andThen(plugin.init(), config.get),
getGlobal: () => Effect.andThen(plugin.init(), config.getGlobal),
getConsoleState: () => Effect.andThen(plugin.init(), config.getConsoleState),
}
}),
).pipe(Layer.provide(Layer.merge(Plugin.defaultLayer, Config.defaultLayer)))
export const AppLayer = Layer.mergeAll(
Npm.defaultLayer,
AppFileSystem.defaultLayer,
Bus.defaultLayer,
Auth.defaultLayer,
Account.defaultLayer,
ConfigWithPluginPriority,
Config.defaultLayer,
Git.defaultLayer,
Ripgrep.defaultLayer,
File.defaultLayer,
@@ -111,16 +94,18 @@ export const AppLayer = Layer.mergeAll(
Truncate.defaultLayer,
ToolRegistry.defaultLayer,
Format.defaultLayer,
InstanceBootstrap.defaultLayer,
InstanceStore.defaultLayer,
Project.defaultLayer,
Vcs.defaultLayer,
Workspace.defaultLayer,
Worktree.appLayer,
Worktree.defaultLayer,
Pty.defaultLayer,
Installation.defaultLayer,
ShareNext.defaultLayer,
SessionShare.defaultLayer,
SyncEvent.defaultLayer,
).pipe(Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer))
).pipe(Layer.provideMerge(Observability.layer))
const rt = ManagedRuntime.make(AppLayer, { memoMap })
type Runtime = Pick<typeof rt, "runSync" | "runPromise" | "runPromiseExit" | "runFork" | "runCallback" | "dispose">
@@ -147,3 +132,15 @@ export const AppRuntime: Runtime = {
},
dispose: () => rt.dispose(),
}
let bootstrapRun: Promise<Effect.Effect<void>>
export function getBootstrapRunEffect(): Promise<Effect.Effect<void>> {
if (!bootstrapRun) {
bootstrapRun = AppRuntime.runPromise(
Effect.gen(function* () {
return (yield* InstanceBootstrap.Service).run
}),
)
}
return bootstrapRun
}

View File

@@ -123,9 +123,7 @@ export const layer = Layer.effect(
const cfgIgnores = cfg.watcher?.ignore ?? []
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
yield* Effect.forkScoped(
subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)]),
)
yield* subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)])
}
if (ctx.project.vcs === "git") {
@@ -137,7 +135,7 @@ export const layer = Layer.effect(
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
(entry) => entry !== "HEAD",
)
yield* Effect.forkScoped(subscribe(vcsDir, ignore))
yield* subscribe(vcsDir, ignore)
}
}
},

View File

@@ -24,7 +24,6 @@ const fail = (err: unknown) =>
text: () => "",
stdout: Buffer.alloc(0),
stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
truncated: false,
}) satisfies Result
export type Kind = "added" | "deleted" | "modified"
@@ -46,28 +45,16 @@ export type Stat = {
readonly deletions: number
}
export type Patch = {
readonly text: string
readonly truncated: boolean
}
export interface PatchOptions {
readonly context?: number
readonly maxOutputBytes?: number
}
export interface Result {
readonly exitCode: number
readonly text: () => string
readonly stdout: Buffer
readonly stderr: Buffer
readonly truncated: boolean
}
export interface Options {
readonly cwd: string
readonly env?: Record<string, string>
readonly maxOutputBytes?: number
}
export interface Interface {
@@ -81,10 +68,6 @@ export interface Interface {
readonly status: (cwd: string) => Effect.Effect<Item[]>
readonly diff: (cwd: string, ref: string) => Effect.Effect<Item[]>
readonly stats: (cwd: string, ref: string) => Effect.Effect<Stat[]>
readonly patch: (cwd: string, ref: string, file: string, options?: PatchOptions) => Effect.Effect<Patch>
readonly patchAll: (cwd: string, ref: string, options?: PatchOptions) => Effect.Effect<Patch>
readonly patchUntracked: (cwd: string, file: string, options?: PatchOptions) => Effect.Effect<Patch>
readonly statUntracked: (cwd: string, file: string) => Effect.Effect<Stat | undefined>
}
const kind = (code: string): Kind => {
@@ -113,31 +96,15 @@ export const layer = Layer.effect(
stderr: "pipe",
})
const handle = yield* spawner.spawn(proc)
const collect = (stream: typeof handle.stdout) =>
Stream.runFold(
stream,
() => ({ chunks: [] as Uint8Array[], bytes: 0, truncated: false }),
(acc, chunk) => {
if (opts.maxOutputBytes === undefined) {
acc.chunks.push(chunk)
acc.bytes += chunk.length
return acc
}
const remaining = opts.maxOutputBytes - acc.bytes
if (remaining > 0) acc.chunks.push(remaining >= chunk.length ? chunk : chunk.slice(0, remaining))
acc.bytes += chunk.length
acc.truncated = acc.truncated || acc.bytes > opts.maxOutputBytes
return acc
},
).pipe(Effect.map((x) => ({ buffer: Buffer.concat(x.chunks), truncated: x.truncated })))
const [stdout, stderr] = yield* Effect.all([collect(handle.stdout), collect(handle.stderr)], { concurrency: 2 })
const [stdout, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
return {
exitCode: yield* handle.exitCode,
text: () => stdout.buffer.toString("utf8"),
stdout: stdout.buffer,
stderr: stderr.buffer,
truncated: stdout.truncated || stderr.truncated,
text: () => stdout,
stdout: Buffer.from(stdout),
stderr: Buffer.from(stderr),
} satisfies Result
},
Effect.scoped,
@@ -273,61 +240,6 @@ export const layer = Layer.effect(
})
})
const patch = Effect.fn("Git.patch")(function* (cwd: string, ref: string, file: string, options?: PatchOptions) {
const result = yield* run(
["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", file],
{ cwd, maxOutputBytes: options?.maxOutputBytes },
)
return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch
})
const patchAll = Effect.fn("Git.patchAll")(function* (cwd: string, ref: string, options?: PatchOptions) {
const result = yield* run(
["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", "."],
{ cwd, maxOutputBytes: options?.maxOutputBytes },
)
return { text: result.text(), truncated: result.truncated } satisfies Patch
})
const patchUntracked = Effect.fn("Git.patchUntracked")(function* (
cwd: string,
file: string,
options?: PatchOptions,
) {
const result = yield* run(
[
"diff",
"--no-index",
"--patch",
"--no-ext-diff",
"--no-renames",
`--unified=${options?.context ?? 3}`,
"--",
"/dev/null",
file,
],
{ cwd, maxOutputBytes: options?.maxOutputBytes },
)
return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch
})
const statUntracked = Effect.fn("Git.statUntracked")(function* (cwd: string, file: string) {
const result = yield* run(["diff", "--no-index", "--numstat", "--", "/dev/null", file], {
cwd,
maxOutputBytes: 4096,
})
if (result.truncated) return
const parts = result.text().split("\t")
if (parts.length < 2) return
const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] || "0", 10)
const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] || "0", 10)
return {
file,
additions: Number.isFinite(additions) ? additions : 0,
deletions: Number.isFinite(deletions) ? deletions : 0,
} satisfies Stat
})
return Service.of({
run,
branch,
@@ -339,10 +251,6 @@ export const layer = Layer.effect(
status,
diff,
stats,
patch,
patchAll,
patchUntracked,
statUntracked,
})
}),
)

View File

@@ -144,6 +144,7 @@ interface State {
}
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
return evalRule(permission, pattern, ...rulesets)
}

View File

@@ -10,7 +10,6 @@ import { Bus } from "../bus"
import * as Log from "@opencode-ai/core/util/log"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { Flag } from "@opencode-ai/core/flag/flag"
import { ServerAuth } from "@/server/auth"
import { CodexAuthPlugin } from "./codex"
import { Session } from "@/session/session"
import { NamedError } from "@opencode-ai/core/util/error"
@@ -125,7 +124,11 @@ export const layer = Layer.effect(
const client = createOpencodeClient({
baseUrl: "http://localhost:4096",
directory: ctx.directory,
headers: ServerAuth.headers(),
headers: Flag.OPENCODE_SERVER_PASSWORD
? {
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
}
: undefined,
fetch: async (...args) => Server.Default().app.fetch(...args),
})
const cfg = yield* config.get()

View File

@@ -1,9 +0,0 @@
import { Context, Effect } from "effect"
export interface Interface {
readonly run: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceBootstrap") {}
export * as InstanceBootstrap from "./bootstrap-service"

View File

@@ -1,3 +1,4 @@
import { Plugin } from "../plugin"
import { Format } from "../format"
import { LSP } from "@/lsp/lsp"
import { File } from "../file"
@@ -5,29 +6,32 @@ import { Snapshot } from "../snapshot"
import * as Project from "./project"
import * as Vcs from "./vcs"
import { Bus } from "../bus"
import { Plugin } from "../plugin"
import { Command } from "../command"
import { InstanceState } from "@/effect/instance-state"
import { FileWatcher } from "@/file/watcher"
import { ShareNext } from "@/share/share-next"
import { Effect, Layer } from "effect"
import { Context, Effect, Layer } from "effect"
import { Config } from "@/config/config"
import { Service } from "./bootstrap-service"
export { Service } from "./bootstrap-service"
export type { Interface } from "./bootstrap-service"
export interface Interface {
readonly run: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceBootstrap") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
// Yield each bootstrap dep at layer init so `run` itself has R = never.
// InstanceStore imports only the lightweight tag from bootstrap-service.ts,
// so it can depend on bootstrap without importing this implementation graph.
// This breaks the circular declaration loop through Config → Instance → InstanceStore
// (instance-store.ts only yields this Service tag, never the impl-side services).
const bus = yield* Bus.Service
const config = yield* Config.Service
const file = yield* File.Service
const fileWatcher = yield* FileWatcher.Service
const format = yield* Format.Service
const lsp = yield* LSP.Service
const project = yield* Project.Service
const plugin = yield* Plugin.Service
const shareNext = yield* ShareNext.Service
const snapshot = yield* Snapshot.Service
const vcs = yield* Vcs.Service
@@ -35,16 +39,20 @@ export const layer = Layer.effect(
const run = Effect.gen(function* () {
const ctx = yield* InstanceState.context
yield* Effect.logInfo("bootstrapping", { directory: ctx.directory })
// everything depends on config so eager load it for nice traces.
// Config can initialise plugins through its layer override.
// everything depends on config so eager load it for nice traces
yield* config.get()
// Each service self-manages its own slow work via Effect.forkScoped against
// its per-instance state scope. We just await materialization here.
yield* Effect.forEach(
[lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project],
(s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))),
{ concurrency: "unbounded", discard: true },
// Plugin can mutate config so it has to be initialized before anything else.
yield* plugin.init()
yield* Effect.all(
[lsp, shareNext, format, file, fileWatcher, vcs, snapshot].map((s) => Effect.forkDetach(s.init())),
).pipe(Effect.withSpan("InstanceBootstrap.init"))
const projectID = ctx.project.id
yield* bus.subscribeCallback(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) {
Project.setInitialized(projectID)
}
})
}).pipe(Effect.withSpan("InstanceBootstrap"))
return Service.of({ run })

View File

@@ -1,11 +0,0 @@
import { Effect, Layer } from "effect"
import { InstanceStore } from "./instance-store"
export const layer = Layer.unwrap(
Effect.promise(async () => {
const { InstanceBootstrap } = await import("./bootstrap")
return InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))
}),
)
export * as InstanceLayer from "./instance-layer"

View File

@@ -1,16 +0,0 @@
import { AppRuntime } from "@/effect/app-runtime"
import { type InstanceContext } from "./instance-context"
import { InstanceStore, type LoadInput } from "./instance-store"
// Bridge for Promise/ALS callers that cannot yet yield InstanceStore.Service.
// Delete this module once those callers are migrated to Effect boundaries that
// provide InstanceStore directly.
export const load = (input: LoadInput) => AppRuntime.runPromise(InstanceStore.Service.use((store) => store.load(input)))
export const disposeInstance = (ctx: InstanceContext) =>
AppRuntime.runPromise(InstanceStore.Service.use((store) => store.dispose(ctx)))
export const disposeAllInstances = () => AppRuntime.runPromise(InstanceStore.Service.use((store) => store.disposeAll()))
export const reloadInstance = (input: LoadInput) =>
AppRuntime.runPromise(InstanceStore.Service.use((store) => store.reload(input)))
export * as InstanceRuntime from "./instance-runtime"

View File

@@ -2,24 +2,32 @@ import { GlobalBus } from "@/bus/global"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { InstanceRef } from "@/effect/instance-ref"
import { disposeInstance as runDisposers } from "@/effect/instance-registry"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect"
import { type InstanceContext } from "./instance-context"
import { InstanceBootstrap } from "./bootstrap-service"
import * as Project from "./project"
export interface LoadInput {
export interface LoadInput<R = never> {
directory: string
/**
* Additional setup to run after the default InstanceBootstrap.
* Mainly used by tests for env-var setup or file writes that need the instance ALS context.
*/
init?: Effect.Effect<void, never, R>
worktree?: string
project?: Project.Info
}
export interface Interface {
readonly load: (input: LoadInput) => Effect.Effect<InstanceContext>
readonly reload: (input: LoadInput) => Effect.Effect<InstanceContext>
readonly load: <R = never>(input: LoadInput<R>) => Effect.Effect<InstanceContext, never, R>
readonly reload: <R = never>(input: LoadInput<R>) => Effect.Effect<InstanceContext, never, R>
readonly dispose: (ctx: InstanceContext) => Effect.Effect<void>
readonly disposeAll: () => Effect.Effect<void>
readonly provide: <A, E, R>(input: LoadInput, effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
readonly provide: <A, E, R, R2 = never>(
input: LoadInput<R2>,
effect: Effect.Effect<A, E, R>,
) => Effect.Effect<A, E, R | R2>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceStore") {}
@@ -28,15 +36,14 @@ interface Entry {
readonly deferred: Deferred.Deferred<InstanceContext>
}
export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootstrap.Service> = Layer.effect(
export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const project = yield* Project.Service
const bootstrap = yield* InstanceBootstrap.Service
const scope = yield* Scope.Scope
const cache = new Map<string, Entry>()
const boot = (input: LoadInput & { directory: string }) =>
const boot = <R>(input: LoadInput<R> & { directory: string }) =>
Effect.gen(function* () {
const ctx: InstanceContext =
input.project && input.worktree
@@ -52,7 +59,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
project: result.project,
})),
)
yield* bootstrap.run.pipe(Effect.provideService(InstanceRef, ctx))
if (input.init) yield* input.init.pipe(Effect.provideService(InstanceRef, ctx))
return ctx
}).pipe(Effect.withSpan("InstanceStore.boot"))
@@ -63,7 +70,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
return true
})
const completeLoad = (directory: string, input: LoadInput, entry: Entry) =>
const completeLoad = <R>(directory: string, input: LoadInput<R>, entry: Entry) =>
Effect.gen(function* () {
const exit = yield* Effect.exit(boot({ ...input, directory }))
if (Exit.isFailure(exit)) yield* removeEntry(directory, entry)
@@ -99,7 +106,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
return true
})
const load = (input: LoadInput): Effect.Effect<InstanceContext> => {
const load = <R>(input: LoadInput<R>): Effect.Effect<InstanceContext, never, R> => {
const directory = AppFileSystem.resolve(input.directory)
return Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
@@ -117,7 +124,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
).pipe(Effect.withSpan("InstanceStore.load"))
}
const reload = (input: LoadInput): Effect.Effect<InstanceContext> => {
const reload = <R>(input: LoadInput<R>): Effect.Effect<InstanceContext, never, R> => {
const directory = AppFileSystem.resolve(input.directory)
return Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
@@ -171,7 +178,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
return yield* cachedDisposeAll
})
const provide = <A, E, R>(input: LoadInput, effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
const provide = <A, E, R, R2>(input: LoadInput<R2>, effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R | R2> =>
load(input).pipe(Effect.flatMap((ctx) => effect.pipe(Effect.provideService(InstanceRef, ctx))))
yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore))
@@ -188,4 +195,13 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
export const defaultLayer = layer.pipe(Layer.provide(Project.defaultLayer))
export const runtime = makeRuntime(Service, defaultLayer)
// Promise-returning helpers for callers without an Effect runtime in scope.
// They route through `runtime` (not a yielded Service from a fresh runtime)
// so they share the cache that `Instance.provide` populates.
export const disposeInstance = (ctx: InstanceContext) => runtime.runPromise((store) => store.dispose(ctx))
export const disposeAllInstances = () => runtime.runPromise((store) => store.disposeAll())
export const reloadInstance = (input: LoadInput) => runtime.runPromise((store) => store.reload(input))
export * as InstanceStore from "./instance-store"

View File

@@ -1,8 +1,17 @@
import { Effect } from "effect"
import { context, type InstanceContext } from "./instance-context"
import { InstanceStore } from "./instance-store"
export type { InstanceContext } from "./instance-context"
export type { LoadInput } from "./instance-store"
export const Instance = {
async provide<R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }): Promise<R> {
const ctx = await InstanceStore.runtime.runPromise((store) =>
store.load({ directory: input.directory, init: input.init }),
)
return context.provide(ctx, async () => input.fn())
},
get current() {
return context.use()
},

View File

@@ -10,9 +10,6 @@ import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { which } from "../util/which"
import { ProjectID } from "./schema"
import { Bus } from "@/bus"
import { Command } from "@/command"
import { InstanceState } from "@/effect/instance-state"
import { Effect, Layer, Path, Scope, Context, Stream, Types, Schema } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodePath } from "@effect/platform-node"
@@ -111,12 +108,6 @@ export type UpdatePayload = Types.DeepMutable<Schema.Schema.Type<typeof UpdatePa
// ---------------------------------------------------------------------------
export interface Interface {
/**
* Per-instance setup. Subscribes to the `/init` slash command for the
* current instance and stamps the project's initialized timestamp when it
* fires. Subscription lifetime is tied to the per-instance state scope.
*/
readonly init: () => Effect.Effect<void>
readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }>
readonly discover: (input: Info) => Effect.Effect<void>
readonly list: () => Effect.Effect<Info[]>
@@ -136,14 +127,13 @@ type GitResult = { code: number; text: string; stderr: string }
export const layer: Layer.Layer<
Service,
never,
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Bus.Service
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner
> = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const pathSvc = yield* Path.Path
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const bus = yield* Bus.Service
const git = Effect.fnUntraced(
function* (args: string[], opts?: { cwd?: string }) {
@@ -427,21 +417,6 @@ export const layer: Layer.Layer<
)
})
const initState = yield* InstanceState.make(
Effect.fn("Project.initState")(function* (ctx) {
yield* bus.subscribe(Command.Event.Executed).pipe(
Stream.runForEach((payload) =>
payload.properties.name === Command.Default.INIT ? setInitialized(ctx.project.id) : Effect.void,
),
Effect.forkScoped,
)
}),
)
const init = Effect.fn("Project.init")(function* () {
yield* InstanceState.get(initState)
})
const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) return []
@@ -491,7 +466,6 @@ export const layer: Layer.Layer<
})
return Service.of({
init,
fromDirectory,
discover,
list,
@@ -507,7 +481,6 @@ export const layer: Layer.Layer<
)
export const defaultLayer = layer.pipe(
Layer.provide(Bus.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodePath.layer),

View File

@@ -1,8 +1,10 @@
import { Effect, Layer, Context, Schema, Stream, Scope } from "effect"
import { formatPatch, structuredPatch } from "diff"
import path from "path"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FileWatcher } from "@/file/watcher"
import { Git } from "@/git"
import * as Log from "@opencode-ai/core/util/log"
@@ -10,11 +12,20 @@ import { zod } from "@/util/effect-zod"
import { NonNegativeInt, withStatics } from "@/util/schema"
const log = Log.create({ service: "vcs" })
const PATCH_CONTEXT_LINES = 2_147_483_647
const MAX_PATCH_BYTES = 10_000_000
const MAX_TOTAL_PATCH_BYTES = 10_000_000
const emptyPatch = (file: string) => formatPatch(structuredPatch(file, file, "", "", "", "", { context: 0 }))
const count = (text: string) => {
if (!text) return 0
if (!text.endsWith("\n")) return text.split("\n").length
return text.slice(0, -1).split("\n").length
}
const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) {
const full = path.join(cwd, file)
if (!(yield* fs.exists(full).pipe(Effect.orDie))) return ""
const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
if (Buffer.from(buf).includes(0)) return ""
return Buffer.from(buf).toString("utf8")
})
const nums = (list: Git.Stat[]) =>
new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const))
@@ -27,168 +38,59 @@ const merge = (...lists: Git.Item[][]) => {
return [...out.values()]
}
const emptyBatch = () => ({ patches: new Map<string, string>(), capped: false })
const parseQuotedPath = (value: string) => {
let out = ""
for (let idx = 1; idx < value.length; idx++) {
const char = value[idx]
if (char === '"') return { value: out, end: idx + 1 }
if (char !== "\\") {
out += char
continue
}
const next = value[++idx]
if (next === "t") out += "\t"
else if (next === "n") out += "\n"
else if (next === "r") out += "\r"
else if (next === '"' || next === "\\") out += next
else out += next ?? ""
}
}
const parsePathToken = (value: string) => {
if (!value.startsWith('"')) return value.split("\t")[0]
return parseQuotedPath(value)?.value ?? value
}
const fileFromDiffPath = (value: string | undefined) => {
if (!value || value === "/dev/null") return
const file = parsePathToken(value)
if (file.startsWith("a/") || file.startsWith("b/")) return file.slice(2)
return file
}
const fileFromGitHeader = (header: string) => {
if (header.startsWith('"')) {
const first = parseQuotedPath(header)
const second = first ? header.slice(first.end).trimStart() : undefined
if (!second) return
if (!second.startsWith('"')) return fileFromDiffPath(second)
return fileFromDiffPath(parseQuotedPath(second)?.value)
}
const separator = header.indexOf(" b/")
if (separator === -1) return
return fileFromDiffPath(header.slice(separator + 1))
}
const fileFromPatchChunk = (chunk: string) => {
const next = /^\+\+\+ (.+)$/m.exec(chunk)?.[1]
const before = /^--- (.+)$/m.exec(chunk)?.[1]
const file = fileFromDiffPath(next) ?? fileFromDiffPath(before)
if (file) return file
const header = /^diff --git (.+)$/m.exec(chunk)?.[1]
return fileFromGitHeader(header ?? "")
}
const splitGitPatch = (patch: Git.Patch) => {
const starts = [...patch.text.matchAll(/^diff --git /gm)].map((match) => match.index)
const chunks = starts.map((start, index) => patch.text.slice(start, starts[index + 1] ?? patch.text.length))
if (!patch.truncated) return chunks
return chunks.slice(0, -1)
}
const batchPatches = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string, list: Git.Item[]) {
if (list.length === 0) return { patches: new Map<string, string>(), capped: false }
const result = yield* git.patchAll(cwd, ref, {
context: PATCH_CONTEXT_LINES,
maxOutputBytes: MAX_TOTAL_PATCH_BYTES,
})
if (result.truncated) log.warn("batched patch exceeded byte limit", { max: MAX_TOTAL_PATCH_BYTES })
return {
patches: splitGitPatch(result).reduce((acc, patch, index) => {
const file = fileFromPatchChunk(patch) ?? list[index]?.file
if (!file) return acc
acc.set(file, (acc.get(file) ?? "") + patch)
return acc
}, new Map<string, string>()),
capped: result.truncated,
}
})
const nativePatch = Effect.fnUntraced(function* (
git: Git.Interface,
cwd: string,
ref: string | undefined,
item: Git.Item,
) {
const result =
item.code === "??" || !ref
? yield* git.patchUntracked(cwd, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES })
: yield* git.patch(cwd, ref, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES })
if (!result.truncated && result.text) return result.text
if (result.truncated) log.warn("patch exceeded byte limit", { file: item.file, max: MAX_PATCH_BYTES })
return emptyPatch(item.file)
})
const totalPatch = (file: string, patch: string, total: number) => {
if (total + Buffer.byteLength(patch) <= MAX_TOTAL_PATCH_BYTES) return { patch, capped: false }
log.warn("total patch budget exceeded", { file, max: MAX_TOTAL_PATCH_BYTES })
return { patch: emptyPatch(file), capped: true }
}
const patchForItem = Effect.fnUntraced(function* (
git: Git.Interface,
cwd: string,
ref: string | undefined,
item: Git.Item,
batch: { patches: Map<string, string>; capped: boolean },
capped: boolean,
) {
if (capped) return emptyPatch(item.file)
const batched = batch.patches.get(item.file)
if (batched !== undefined) return batched
if (item.code !== "??" && batch.capped) return emptyPatch(item.file)
return yield* nativePatch(git, cwd, ref, item)
})
const files = Effect.fnUntraced(function* (
fs: AppFileSystem.Interface,
git: Git.Interface,
cwd: string,
ref: string | undefined,
list: Git.Item[],
map: Map<string, { additions: number; deletions: number }>,
batch: { patches: Map<string, string>; capped: boolean },
) {
const next: FileDiff[] = []
let total = 0
let capped = false
for (const item of list.toSorted((a, b) => a.file.localeCompare(b.file))) {
const stat = map.get(item.file) ?? (item.status === "added" ? yield* git.statUntracked(cwd, item.file) : undefined)
const patch = yield* patchForItem(git, cwd, ref, item, batch, capped)
const result: { patch: string; capped: boolean } = capped
? { patch, capped: true }
: totalPatch(item.file, patch, total)
capped = capped || result.capped
if (!capped) {
total += Buffer.byteLength(result.patch)
capped = total >= MAX_TOTAL_PATCH_BYTES
}
next.push({
file: item.file,
patch: result.patch,
additions: stat?.additions ?? 0,
deletions: stat?.deletions ?? 0,
status: item.status,
})
}
return next
const base = ref ? yield* git.prefix(cwd) : ""
const patch = (file: string, before: string, after: string) =>
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
const next = yield* Effect.forEach(
list,
(item) =>
Effect.gen(function* () {
const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base)
const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file)
const stat = map.get(item.file)
return {
file: item.file,
patch: patch(item.file, before, after),
additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
status: item.status,
} satisfies FileDiff
}),
{ concurrency: 8 },
)
return next.toSorted((a, b) => a.file.localeCompare(b.file))
})
const diffAgainstRef = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string) {
const track = Effect.fnUntraced(function* (
fs: AppFileSystem.Interface,
git: Git.Interface,
cwd: string,
ref: string | undefined,
) {
if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map())
const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 })
return yield* files(fs, git, cwd, ref, list, nums(stats))
})
const compare = Effect.fnUntraced(function* (
fs: AppFileSystem.Interface,
git: Git.Interface,
cwd: string,
ref: string,
) {
const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], {
concurrency: 3,
})
return yield* files(
fs,
git,
cwd,
ref,
@@ -197,15 +99,9 @@ const diffAgainstRef = Effect.fnUntraced(function* (git: Git.Interface, cwd: str
extra.filter((item) => item.code === "??"),
),
nums(stats),
yield* batchPatches(git, cwd, ref, list),
)
})
const track = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string | undefined) {
if (!ref) return yield* files(git, cwd, ref, yield* git.status(cwd), new Map(), emptyBatch())
return yield* diffAgainstRef(git, cwd, ref)
})
export const Mode = Schema.Literals(["git", "branch"]).pipe(withStatics((s) => ({ zod: zod(s) })))
export type Mode = Schema.Schema.Type<typeof Mode>
@@ -251,9 +147,10 @@ interface State {
export class Service extends Context.Service<Service, Interface>()("@opencode/Vcs") {}
export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Layer.effect(
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service | Bus.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
const bus = yield* Bus.Service
const scope = yield* Scope.Scope
@@ -307,19 +204,23 @@ export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Lay
const ctx = yield* InstanceState.context
if (ctx.project.vcs !== "git") return []
if (mode === "git") {
return yield* track(git, ctx.directory, (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined)
return yield* track(fs, git, ctx.directory, (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined)
}
if (!value.root) return []
if (value.current && value.current === value.root.name) return []
const ref = yield* git.mergeBase(ctx.directory, value.root.ref)
if (!ref) return []
return yield* diffAgainstRef(git, ctx.directory, ref)
return yield* compare(fs, git, ctx.directory, ref)
}),
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Git.defaultLayer), Layer.provide(Bus.layer))
export const defaultLayer = layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Bus.layer),
)
export * as Vcs from "./vcs"

View File

@@ -1,12 +0,0 @@
import { AppRuntime } from "@/effect/app-runtime"
import { context } from "./instance-context"
import { InstanceStore } from "./instance-store"
export async function provide<R>(input: { directory: string; fn: () => R }): Promise<R> {
const ctx = await AppRuntime.runPromise(
InstanceStore.Service.use((store) => store.load({ directory: input.directory })),
)
return context.provide(ctx, () => input.fn())
}
export * as WithInstance from "./with-instance"

View File

@@ -1,48 +0,0 @@
export * as ServerAuth from "./auth"
import { ConfigService } from "@/effect/config-service"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Config as EffectConfig, Context, Option, Redacted } from "effect"
export type Credentials = {
password?: string
username?: string
}
export type DecodedCredentials = {
readonly username: string
readonly password: Redacted.Redacted
}
export class Config extends ConfigService.Service<Config>()("@opencode/ServerAuthConfig", {
password: EffectConfig.string("OPENCODE_SERVER_PASSWORD").pipe(EffectConfig.option),
username: EffectConfig.string("OPENCODE_SERVER_USERNAME").pipe(EffectConfig.withDefault("opencode")),
}) {}
export type Info = Context.Service.Shape<typeof Config>
export function required(config: Info) {
return Option.isSome(config.password) && config.password.value !== ""
}
export function authorized(credentials: DecodedCredentials, config: Info) {
return (
Option.isSome(config.password) &&
credentials.username === config.username &&
Redacted.value(credentials.password) === config.password.value
)
}
export function header(credentials?: Credentials) {
const password = credentials?.password ?? Flag.OPENCODE_SERVER_PASSWORD
if (!password) return undefined
const username = credentials?.username ?? Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
}
export function headers(credentials?: Credentials) {
const authorization = header(credentials)
if (!authorization) return undefined
return { Authorization: authorization }
}

View File

@@ -1,8 +1,78 @@
import type { MiddlewareHandler } from "hono"
import { Database } from "@/storage/db"
import { inArray } from "drizzle-orm"
import { EventSequenceTable } from "@/sync/event.sql"
import { Workspace } from "@/control-plane/workspace"
import type { WorkspaceID } from "@/control-plane/schema"
import * as Log from "@opencode-ai/core/util/log"
import { HEADER, diff, load } from "./shared/fence"
import { AppRuntime } from "@/effect/app-runtime"
import { Effect } from "effect"
const log = Log.create({ service: "fence-middleware" })
const HEADER = "x-opencode-sync"
type State = Record<string, number>
const log = Log.create({ service: "fence" })
export function load(ids?: string[]) {
const rows = Database.use((db) => {
if (!ids?.length) {
return db.select().from(EventSequenceTable).all()
}
return db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all()
})
return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq])) as State
}
export function diff(prev: State, next: State) {
const ids = new Set([...Object.keys(prev), ...Object.keys(next)])
return Object.fromEntries(
[...ids]
.map((id) => [id, next[id] ?? -1] as const)
.filter(([id, seq]) => {
return (prev[id] ?? -1) !== seq
}),
) as State
}
export function parse(headers: Headers) {
const raw = headers.get(HEADER)
if (!raw) return
let data
try {
data = JSON.parse(raw)
} catch {
return
}
if (!data || typeof data !== "object") return
return Object.fromEntries(
Object.entries(data).filter(([id, seq]) => {
return typeof id === "string" && Number.isInteger(seq)
}),
) as State
}
export function waitEffect(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
return Effect.gen(function* () {
log.info("waiting for state", {
workspaceID,
state,
})
yield* Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal))
log.info("state fully synced", {
workspaceID,
state,
})
})
}
export async function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
await AppRuntime.runPromise(waitEffect(workspaceID, state, signal))
}
export const FenceMiddleware: MiddlewareHandler = async (c, next) => {
if (c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS") return next()

View File

@@ -1,37 +0,0 @@
import { GlobalBus } from "@/bus/global"
import { InstanceStore } from "@/project/instance-store"
import * as Log from "@opencode-ai/core/util/log"
import { Effect } from "effect"
import { Event } from "./event"
const log = Log.create({ service: "server" })
export const emitGlobalDisposed = Effect.sync(() =>
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Event.Disposed.type,
properties: {},
},
}),
)
export const disposeAllInstancesAndEmitGlobalDisposed = Effect.fn("Server.disposeAllInstancesAndEmitGlobalDisposed")(
function* (options?: { swallowErrors?: boolean }) {
const store = yield* InstanceStore.Service
yield* Effect.gen(function* () {
yield* options?.swallowErrors
? store.disposeAll().pipe(
Effect.catchCause((cause) =>
Effect.sync(() => {
log.warn("global disposal failed", { cause })
}),
),
)
: store.disposeAll()
yield* emitGlobalDisposed
}).pipe(Effect.uninterruptible)
},
)
export * as GlobalLifecycle from "./global-lifecycle"

View File

@@ -1,244 +0,0 @@
// TODO: Node adapter forthcoming — same pattern but using `node:http` + `ws` library,
// and `node:http`'s `upgrade` event.
//
// This module is a Bun-only proof-of-concept for a native `Bun.serve` listener that
// drives the experimental HttpApi handler directly (no Hono in the middle) and handles
// WebSocket upgrades inline based on path-matching. It exists to validate the pattern
// before deleting the Hono backend; `Server.listen()` is intentionally NOT wired to it.
import type { ServerWebSocket } from "bun"
import { Effect, Schema } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { WithInstance } from "@/project/with-instance"
import { Pty } from "@/pty"
import { handlePtyInput } from "@/pty/input"
import { PtyID } from "@/pty/schema"
import { PtyPaths } from "@/server/routes/instance/httpapi/groups/pty"
import { ExperimentalHttpApiServer } from "@/server/routes/instance/httpapi/server"
import * as Log from "@opencode-ai/core/util/log"
import type { CorsOptions } from "./cors"
const log = Log.create({ service: "httpapi-listener" })
const decodePtyID = Schema.decodeUnknownSync(PtyID)
export type Listener = {
hostname: string
port: number
url: URL
stop: (close?: boolean) => Promise<void>
}
export type ListenOptions = CorsOptions & {
port: number
hostname: string
}
type WsKind = { kind: "pty"; ptyID: string; cursor: number | undefined; directory: string }
type PtyHandler = {
onMessage: (message: string | ArrayBuffer) => void
onClose: () => void
}
type WsState = WsKind & {
handler?: PtyHandler
pending: Array<string | Uint8Array>
ready: boolean
closed: boolean
}
// Derive from the OpenAPI path so this stays in sync if the route literal moves.
const ptyConnectPattern = new RegExp(`^${PtyPaths.connect.replace(/:[^/]+/g, "([^/]+)")}$`)
function parseCursor(value: string | null): number | undefined {
if (!value) return undefined
const parsed = Number(value)
if (!Number.isSafeInteger(parsed) || parsed < -1) return undefined
return parsed
}
function asAdapter(ws: ServerWebSocket<WsState>) {
return {
get readyState() {
return ws.readyState
},
send: (data: string | Uint8Array | ArrayBuffer) => {
try {
if (data instanceof ArrayBuffer) ws.send(new Uint8Array(data))
else ws.send(data)
} catch {
// socket likely already closed; ignore
}
},
close: (code?: number, reason?: string) => {
try {
ws.close(code, reason)
} catch {
// ignore
}
},
}
}
/**
* Spin up a native Bun.serve that:
* 1. Routes all HTTP traffic through the HttpApi web handler.
* 2. Intercepts known WebSocket upgrade paths and handles them inline.
*
* This bypasses Hono entirely. The Hono code path remains untouched.
*/
export async function listen(opts: ListenOptions): Promise<Listener> {
const built = ExperimentalHttpApiServer.webHandler(opts)
const handler = built.handler
const context = ExperimentalHttpApiServer.context
const start = (port: number) => {
try {
return Bun.serve<WsState>({
hostname: opts.hostname,
port,
idleTimeout: 0,
fetch(request, server) {
const url = new URL(request.url)
const ptyMatch = url.pathname.match(ptyConnectPattern)
if (ptyMatch && request.headers.get("upgrade")?.toLowerCase() === "websocket") {
const ptyID = ptyMatch[1]!
const cursor = parseCursor(url.searchParams.get("cursor"))
// Resolve the instance directory the same way the HttpApi
// `instance-context` middleware does (search params, then header,
// then process.cwd()).
const directory =
url.searchParams.get("directory") ?? request.headers.get("x-opencode-directory") ?? process.cwd()
const upgraded = server.upgrade(request, {
data: {
kind: "pty",
ptyID,
cursor,
directory,
pending: [],
ready: false,
closed: false,
} satisfies WsState,
})
if (upgraded) return undefined
return new Response("upgrade failed", { status: 400 })
}
// TODO: workspace-proxy WS upgrade detection. The Hono path forwards via a
// remote `new WebSocket(url, ...)` (see ServerProxy.websocket). To support
// that here we'd need to (a) resolve the workspace target the same way
// `WorkspaceRouterMiddleware` does today, then (b) `server.upgrade(request,
// { data: { kind: "proxy", target, headers, protocols } })` and bridge the
// ServerWebSocket to a remote WebSocket inside the `websocket` handlers.
// Deferred to a follow-up — the proxy story needs more design (auth header
// forwarding, fence sync, reconnection semantics) than fits this PR.
return handler(request as Request, context as never)
},
websocket: {
open(ws) {
const data = ws.data
if (data.kind !== "pty") {
ws.close(1011, "unknown ws kind")
return
}
const id = (() => {
try {
return decodePtyID(data.ptyID)
} catch {
ws.close(1008, "invalid pty id")
return undefined
}
})()
if (!id) return
;(async () => {
const result = await WithInstance.provide({
directory: data.directory,
fn: () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.connect(id, asAdapter(ws), data.cursor)
}).pipe(Effect.withSpan("HttpApiListener.pty.connect.open")),
),
})
return await result
})()
.then((handler) => {
if (data.closed) {
handler?.onClose()
return
}
if (!handler) {
ws.close(4404, "session not found")
return
}
data.handler = handler
data.ready = true
for (const msg of data.pending) {
AppRuntime.runPromise(handlePtyInput(handler, msg)).catch(() => undefined)
}
data.pending.length = 0
})
.catch((err) => {
log.error("pty connect failed", { error: err })
ws.close(1011, "pty connect failed")
})
},
message(ws, message) {
const data = ws.data
if (data.kind !== "pty") return
const payload =
typeof message === "string"
? message
: message instanceof Buffer
? new Uint8Array(message.buffer, message.byteOffset, message.byteLength)
: (message as Uint8Array)
if (!data.ready || !data.handler) {
data.pending.push(payload)
return
}
AppRuntime.runPromise(handlePtyInput(data.handler, payload)).catch(() => undefined)
},
close(ws) {
const data = ws.data
data.closed = true
data.handler?.onClose()
},
},
})
} catch (err) {
log.error("Bun.serve failed", { error: err })
return undefined
}
}
const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port)
if (!server) throw new Error(`Failed to start server on port ${opts.port}`)
const port = server.port
if (port === undefined) throw new Error("Bun.serve started without a numeric port")
const url = new URL("http://localhost")
url.hostname = opts.hostname
url.port = String(port)
let closing: Promise<void> | undefined
return {
hostname: opts.hostname,
port,
url,
stop(close?: boolean) {
closing ??= (async () => {
await server.stop(close)
// NOTE: we deliberately do NOT call `built.dispose()` here. The
// underlying `webHandler` is memoized at module level (same as the
// Hono path), so disposing it would tear down shared services for
// every other consumer in the process. Lifecycle teardown is owned
// by the AppRuntime itself.
})()
return closing
},
}
}
export * as HttpApiListener from "./httpapi-listener"

View File

@@ -1,7 +1,7 @@
import { Hono } from "hono"
import type { UpgradeWebSocket } from "hono/ws"
import * as Log from "@opencode-ai/core/util/log"
import * as Fence from "./shared/fence"
import * as Fence from "./fence"
import type { WorkspaceID } from "@/control-plane/schema"
import { Workspace } from "@/control-plane/workspace"
import { AppRuntime } from "@/effect/app-runtime"

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