Compare commits

..
80 Commits
Author SHA1 Message Date
opencode 2b92c5677e release: v1.15.4 2026-05-17 17:44:40 +00:00
Frank 468eb68878 zen: update monitoring query 2026-05-17 13:15:39 -04:00
DaxandGitHub 23b594de6e fix: preserve bus instance context (#28051) 2026-05-17 12:56:42 -04:00
Frank 5060577ee1 sync 2026-05-17 12:37:47 -04:00
Aiden ClineGitHubopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>rekram1-node
d3ebb1f7c0 ignore: cleanup readme (#28049)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-05-17 11:26:23 -05:00
Shoubhit DashandGitHub e4cc4e1682 fix(lsp): preserve instance ref for update events (#28016) 2026-05-17 16:37:00 +05:30
opencode-agent[bot] 53e89f9d52 chore: generate 2026-05-17 08:06:12 +00:00
Frank de247b7aae sync 2026-05-17 04:04:39 -04:00
Frank 0b8050d453 sync 2026-05-17 01:59:20 -04:00
be6a89a3b8 docs: update README.es.md (#27739)
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
2026-05-16 19:33:24 +02:00
Kit LangtonandGitHub c0a8b509c7 style(tui): distinguish markdown h1 headings (#27814) 2026-05-16 11:18:17 -04:00
opencode f80651fa91 sync release versions for v1.15.3 2026-05-16 14:07:33 +00:00
Shoubhit DashandGitHub 68af95390d fix: hide task background instructions by default (#27872) 2026-05-16 19:18:15 +05:30
opencode-agent[bot] 321db7a819 chore: generate 2026-05-16 13:27:23 +00:00
Dax Raad 6d2219e001 fix(cli): preserve instance context in async commands 2026-05-16 09:25:45 -04:00
opencode-agent[bot] dd432e3cde chore: generate 2026-05-16 13:21:57 +00:00
Dax Raad 77db212a0a fix read stream byte cap 2026-05-16 09:20:35 -04:00
opencode f56263791c sync release versions for v1.15.2 2026-05-16 12:53:10 +00:00
Dax Raad 88363f1ed9 fixed issue in opencode run 2026-05-16 08:27:25 -04:00
JackandGitHub c5db39f626 Remove Ring 2.6 1T from Zen docs (#27849) 2026-05-16 17:55:32 +08:00
vimtor b5aed287ca chore: reduce alerts noise 2026-05-16 09:39:13 +02:00
DaxandGitHub 53849bd866 fix(sync): publish events on injected project bus (#27825) 2026-05-16 05:56:49 +00:00
opencode e33912bfee sync release versions for v1.15.1 2026-05-16 05:44:44 +00:00
Aiden ClineandGitHub 548648a3d9 core: reduce prompts for shell, todowrite, and task tools (#26821) 2026-05-16 00:18:47 -05:00
Caleb NortonandGitHub 4643e13170 feat(nix): add opencode-electron derivation (#16163) 2026-05-15 23:20:48 -05:00
Dax Raad 042e6a5c86 tui: newly pinned sessions now append to the end of the list instead of jumping to the top 2026-05-15 23:57:40 -04:00
Dax Raad e36d6a0cbe core: clarify postinstall error message when binary is missing
Users installing with pnpm or --ignore-scripts now see a clear explanation
of why the native binary is missing and exact steps to fix it. The shell
script wrapper provides more actionable guidance than the previous Node.js
fallback that only suggested running the postinstall script without context
about why it wasn't run automatically.
2026-05-15 23:12:00 -04:00
SebastianandGitHub cc9c0b15c7 Revert "add dialog prompt submit keybind (#27807)" (#27815) 2026-05-16 04:44:32 +02:00
Kit LangtonandGitHub f3b0d3d7ac fix(tui): dedupe consecutive prompt history entries (#27816) 2026-05-16 02:36:35 +00:00
opencode-agent[bot] 764c6bc517 chore: update nix node_modules hashes 2026-05-16 01:20:56 +00:00
SebastianandGitHub d441e931f9 add dialog prompt submit keybind (#27807) 2026-05-16 03:11:46 +02:00
SebastianandGitHub ad79ad9ea8 upgrade opentui to 0.2.11 (#27808) 2026-05-16 03:05:54 +02:00
opencode-agent[bot] d6b23fd8f6 chore: generate 2026-05-16 00:44:10 +00:00
Kit LangtonandGitHub 5911bd532d fix(tui): show config error details on startup (#27803) 2026-05-15 20:42:56 -04:00
Dax Raad 2385123f03 Fix thinking toggle defaults 2026-05-15 20:27:36 -04:00
DaxandGitHub 09549661e1 Fix npm CLI binary installation (#27801) 2026-05-15 18:43:37 -04:00
opencode-agent[bot] da495fd2e0 chore: generate 2026-05-15 22:09:43 +00:00
vimtor 85cd447910 chore: reduce alerts noise 2026-05-16 00:07:59 +02:00
SebastianandGitHub 0f31fd631b Fix multiline mentions (#27649) 2026-05-15 23:04:20 +02:00
SebastianandGitHub aa07e21945 handle undefined tips (#27635) 2026-05-15 23:04:00 +02:00
Shoubhit DashandGitHub f060874b29 feat(tui): add minimal thinking mode with click-to-expand (#27623) 2026-05-16 02:09:58 +05:30
vimtor f21c582db9 chore: reduce alerting noise 2026-05-15 22:18:50 +02:00
Shoubhit DashandGitHub 65f96a5851 refactor(instance): retire WithInstance adapter (#27782) 2026-05-16 01:30:07 +05:30
Aiden ClineandGitHub 48122b31cc fix(tool): bridge custom tool zod metadata (#27770) 2026-05-15 14:50:21 -05:00
opencode-agent[bot] 0df2f5b45f chore: generate 2026-05-15 19:48:45 +00:00
Shoubhit DashandGitHub 499e8e4b78 test(instance): add effect-native fixture helpers (#27781) 2026-05-16 01:17:37 +05:30
Shoubhit DashandGitHub f33b4455a1 feat(tui): enable pinned session switching (#27780) 2026-05-16 01:10:16 +05:30
Shoubhit DashandGitHub a24abd2b11 refactor(lsp): require explicit instance context (#27767) 2026-05-16 00:46:27 +05:30
opencode-agent[bot] d44bef2107 chore: generate 2026-05-15 18:45:24 +00:00
Shoubhit DashandGitHub f99339e525 fix(tui): keep session switching pinned-only (#27775) 2026-05-16 00:14:07 +05:30
Shoubhit DashandGitHub 2b0e72ab79 refactor(workspace): centralize adapter invocation (#27768) 2026-05-15 23:59:01 +05:30
Shoubhit DashandGitHub 2fdee50b3b refactor(acp): extract runtime reentry (#27769) 2026-05-15 23:58:52 +05:30
opencode-agent[bot] 48293c5271 chore: generate 2026-05-15 17:37:07 +00:00
Shoubhit DashandGitHub 0c9cfe923f refactor(instance): remove legacy runtime fallback (#27757) 2026-05-15 23:05:44 +05:30
opencode-agent[bot] 9975c1ed1c chore: generate 2026-05-15 15:12:20 +00:00
ef7d801271 fix(tool): preserve custom tool arg descriptions (#27750)
Co-authored-by: khimaros <231498+khimaros@users.noreply.github.com>
2026-05-15 10:11:01 -05:00
opencode-agent[bot] eb630075c3 chore: generate 2026-05-15 15:01:55 +00:00
Shoubhit DashandGitHub a2392ca60d refactor(worktree): provide runtime reentry refs (#27754) 2026-05-15 20:30:29 +05:30
opencode-agent[bot] f9371eb66c chore: generate 2026-05-15 14:36:04 +00:00
Shoubhit DashandGitHub fa9a2cb24d refactor(instance): remove remaining bind call sites (#27731) 2026-05-15 20:04:42 +05:30
Victor NavarroandGitHub 2d90f325fc ci: catch provider errors across all opencode tiers (#27495) 2026-05-15 16:31:59 +02:00
Aiden ClineandGitHub c2ffd7cf14 fix: markdown table rendering (#27747) 2026-05-15 09:22:24 -05:00
vimtor 104f5d5a14 chore: exclude provider from triggers 2026-05-15 16:01:11 +02:00
Shoubhit DashandGitHub 1c7c03332e test(workspace): avoid legacy instance reads (#27727) 2026-05-15 17:07:21 +05:30
opencode-agent[bot] 984eefa6f8 chore: generate 2026-05-15 11:02:14 +00:00
Shoubhit DashandGitHub bf64f8cbb5 refactor(cli): dispose bootstrap instance explicitly (#27721) 2026-05-15 16:30:54 +05:30
opencode-agent[bot] 727a83aa7a chore: generate 2026-05-15 10:46:06 +00:00
Shoubhit DashandGitHub e65383810a refactor(tool): read repo overview directory from instance state (#27717) 2026-05-15 16:14:49 +05:30
Shoubhit DashandGitHub 12b666e2c9 refactor(project): import instance context directly (#27714) 2026-05-15 15:59:56 +05:30
Shoubhit DashandGitHub eb5ef1c073 refactor(flags): remove unused flag exports (#27709) 2026-05-15 15:35:24 +05:30
Shoubhit DashandGitHub 356f684186 refactor(flags): migrate skip migrations flag (#27705) 2026-05-15 14:54:29 +05:30
Shoubhit DashandGitHub 7b370406a9 refactor(flags): migrate lsp download flag (#27699) 2026-05-15 14:35:31 +05:30
Shoubhit DashandGitHub 202cc863b4 refactor(flags): migrate claude code prompt flag (#27690) 2026-05-15 14:17:04 +05:30
Shoubhit DashandGitHub 22cb0395e2 refactor(flags): migrate external skills flag (#27685) 2026-05-15 13:24:56 +05:30
Shoubhit DashandGitHub 2d6bedecd4 refactor(flags): migrate output token max to runtime flags (#27680) 2026-05-15 13:07:35 +05:30
opencode-agent[bot] 2080390ca6 chore: generate 2026-05-15 07:36:10 +00:00
1ac3f09468 fix(watcher): resolve symlinked .git path before subscribing (#27016)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-05-15 09:34:53 +02:00
Aiden ClineandGitHub ca8f578f2f ci: skip previously cleaned PRs (#27670) 2026-05-15 00:23:09 -05:00
Aiden ClineandGitHub d59d99665b ci: Automate PR cleanup (#27667) 2026-05-14 23:47:59 -05:00
opencode c43edc5b71 sync release versions for v1.15.0 2026-05-15 04:03:54 +00:00
199 changed files with 6323 additions and 3266 deletions
+50
View File
@@ -0,0 +1,50 @@
name: close-prs
on:
schedule:
- cron: "0 22 * * *" # Daily at 10:00 PM UTC
workflow_dispatch:
inputs:
dry-run:
description: "Log matching PRs without closing them"
type: boolean
default: true
max-close:
description: "Maximum matching PRs to close"
type: string
required: false
default: "50"
jobs:
close:
runs-on: ubuntu-latest
timeout-minutes: 240
permissions:
contents: read
issues: write
pull-requests: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: Close old PRs without enough positive reactions
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
max_close="${{ inputs['max-close'] }}"
if [ -z "$max_close" ]; then
max_close="50"
fi
args=("--threshold" "2" "--age-months" "1" "--sleep-ms" "20000" "--max-close" "$max_close")
if [ "${{ github.event_name }}" = "schedule" ]; then
args+=("--execute")
elif [ "${{ inputs['dry-run'] }}" = "false" ]; then
args+=("--execute")
fi
bun script/github/close-prs.ts "${args[@]}"
-235
View File
@@ -1,235 +0,0 @@
name: close-stale-prs
on:
workflow_dispatch:
inputs:
dryRun:
description: "Log actions without closing PRs"
type: boolean
default: false
schedule:
- cron: "0 6 * * *"
permissions:
contents: read
issues: write
pull-requests: write
jobs:
close-stale-prs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Close inactive PRs
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const DAYS_INACTIVE = 60
const MAX_RETRIES = 3
// Adaptive delay: fast for small batches, slower for large to respect
// GitHub's 80 content-generating requests/minute limit
const SMALL_BATCH_THRESHOLD = 10
const SMALL_BATCH_DELAY_MS = 1000 // 1s for daily operations (≤10 PRs)
const LARGE_BATCH_DELAY_MS = 2000 // 2s for backlog (>10 PRs) = ~30 ops/min, well under 80 limit
const startTime = Date.now()
const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000)
const { owner, repo } = context.repo
const dryRun = context.payload.inputs?.dryRun === "true"
core.info(`Dry run mode: ${dryRun}`)
core.info(`Cutoff date: ${cutoff.toISOString()}`)
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function withRetry(fn, description = 'API call') {
let lastError
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const result = await fn()
return result
} catch (error) {
lastError = error
const isRateLimited = error.status === 403 &&
(error.message?.includes('rate limit') || error.message?.includes('secondary'))
if (!isRateLimited) {
throw error
}
// Parse retry-after header, default to 60 seconds
const retryAfter = error.response?.headers?.['retry-after']
? parseInt(error.response.headers['retry-after'])
: 60
// Exponential backoff: retryAfter * 2^attempt
const backoffMs = retryAfter * 1000 * Math.pow(2, attempt)
core.warning(`${description}: Rate limited (attempt ${attempt + 1}/${MAX_RETRIES}). Waiting ${backoffMs / 1000}s before retry...`)
await sleep(backoffMs)
}
}
core.error(`${description}: Max retries (${MAX_RETRIES}) exceeded`)
throw lastError
}
const query = `
query($owner: String!, $repo: String!, $cursor: String) {
repository(owner: $owner, name: $repo) {
pullRequests(first: 100, states: OPEN, after: $cursor) {
pageInfo {
hasNextPage
endCursor
}
nodes {
number
title
author {
login
}
createdAt
commits(last: 1) {
nodes {
commit {
committedDate
}
}
}
comments(last: 1) {
nodes {
createdAt
}
}
reviews(last: 1) {
nodes {
createdAt
}
}
}
}
}
}
`
const allPrs = []
let cursor = null
let hasNextPage = true
let pageCount = 0
while (hasNextPage) {
pageCount++
core.info(`Fetching page ${pageCount} of open PRs...`)
const result = await withRetry(
() => github.graphql(query, { owner, repo, cursor }),
`GraphQL page ${pageCount}`
)
allPrs.push(...result.repository.pullRequests.nodes)
hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage
cursor = result.repository.pullRequests.pageInfo.endCursor
core.info(`Page ${pageCount}: fetched ${result.repository.pullRequests.nodes.length} PRs (total: ${allPrs.length})`)
// Delay between pagination requests (use small batch delay for reads)
if (hasNextPage) {
await sleep(SMALL_BATCH_DELAY_MS)
}
}
core.info(`Found ${allPrs.length} open pull requests`)
const stalePrs = allPrs.filter((pr) => {
const dates = [
new Date(pr.createdAt),
pr.commits.nodes[0] ? new Date(pr.commits.nodes[0].commit.committedDate) : null,
pr.comments.nodes[0] ? new Date(pr.comments.nodes[0].createdAt) : null,
pr.reviews.nodes[0] ? new Date(pr.reviews.nodes[0].createdAt) : null,
].filter((d) => d !== null)
const lastActivity = dates.sort((a, b) => b.getTime() - a.getTime())[0]
if (!lastActivity || lastActivity > cutoff) {
core.info(`PR #${pr.number} is fresh (last activity: ${lastActivity?.toISOString() || "unknown"})`)
return false
}
core.info(`PR #${pr.number} is STALE (last activity: ${lastActivity.toISOString()})`)
return true
})
if (!stalePrs.length) {
core.info("No stale pull requests found.")
return
}
core.info(`Found ${stalePrs.length} stale pull requests`)
// ============================================
// Close stale PRs
// ============================================
const requestDelayMs = stalePrs.length > SMALL_BATCH_THRESHOLD
? LARGE_BATCH_DELAY_MS
: SMALL_BATCH_DELAY_MS
core.info(`Using ${requestDelayMs}ms delay between operations (${stalePrs.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`)
let closedCount = 0
let skippedCount = 0
for (const pr of stalePrs) {
const issue_number = pr.number
const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.`
if (dryRun) {
core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
continue
}
try {
// Add comment
await withRetry(
() => github.rest.issues.createComment({
owner,
repo,
issue_number,
body: closeComment,
}),
`Comment on PR #${issue_number}`
)
// Close PR
await withRetry(
() => github.rest.pulls.update({
owner,
repo,
pull_number: issue_number,
state: "closed",
}),
`Close PR #${issue_number}`
)
closedCount++
core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
// Delay before processing next PR
await sleep(requestDelayMs)
} catch (error) {
skippedCount++
core.error(`Failed to close PR #${issue_number}: ${error.message}`)
}
}
const elapsed = Math.round((Date.now() - startTime) / 1000)
core.info(`\n========== Summary ==========`)
core.info(`Total open PRs found: ${allPrs.length}`)
core.info(`Stale PRs identified: ${stalePrs.length}`)
core.info(`PRs closed: ${closedCount}`)
core.info(`PRs skipped (errors): ${skippedCount}`)
core.info(`Elapsed time: ${elapsed}s`)
core.info(`=============================`)
+1
View File
@@ -7,6 +7,7 @@ on:
- ci
- dev
- beta
- fix/npm-native-binary-install
- snapshot-*
workflow_dispatch:
inputs:
-12
View File
@@ -124,18 +124,6 @@ XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
اذا كنت تعمل على مشروع مرتبط بـ OpenCode ويستخدم "opencode" كجزء من اسمه (مثل "opencode-dashboard" او "opencode-mobile")، يرجى اضافة ملاحظة في README توضح انه ليس مبنيا بواسطة فريق OpenCode ولا يرتبط بنا بأي شكل.
### FAQ
#### ما الفرق عن Claude Code؟
هو مشابه جدا لـ Claude Code من حيث القدرات. هذه هي الفروقات الاساسية:
- 100% مفتوح المصدر
- غير مقترن بمزود معين. نوصي بالنماذج التي نوفرها عبر [OpenCode Zen](https://opencode.ai/zen)؛ لكن يمكن استخدام OpenCode مع Claude او OpenAI او Google او حتى نماذج محلية. مع تطور النماذج ستتقلص الفجوات وستنخفض الاسعار، لذا من المهم ان يكون مستقلا عن المزود.
- دعم LSP جاهز للاستخدام
- تركيز على TUI. تم بناء OpenCode بواسطة مستخدمي neovim ومنشئي [terminal.shop](https://terminal.shop)؛ وسندفع حدود ما هو ممكن داخل الطرفية.
- معمارية عميل/خادم. على سبيل المثال، يمكن تشغيل OpenCode على جهازك بينما تقوده عن بعد من تطبيق جوال. هذا يعني ان واجهة TUI هي واحدة فقط من العملاء الممكنين.
---
**انضم الى مجتمعنا** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
-12
View File
@@ -124,18 +124,6 @@ OpenCode এ দুটি বিল্ট-ইন এজেন্ট রয়ে
আপনি যদি এমন প্রজেক্টে কাজ করেন যা OpenCode এর সাথে সম্পর্কিত এবং প্রজেক্টের নামের অংশ হিসেবে "opencode" ব্যবহার করেন, উদাহরণস্বরূপ "opencode-dashboard" বা "opencode-mobile", তবে দয়া করে আপনার README তে একটি নোট যোগ করে স্পষ্ট করুন যে এই প্রজেক্টটি OpenCode দল দ্বারা তৈরি হয়নি এবং আমাদের সাথে এর কোনো সরাসরি সম্পর্ক নেই।
### সচরাচর জিজ্ঞাসিত প্রশ্নাবলী (FAQ)
#### এটি ক্লড কোড (Claude Code) থেকে কীভাবে আলাদা?
ক্যাপাবিলিটির দিক থেকে এটি ক্লড কোডের (Claude Code) মতই। এখানে মূল পার্থক্যগুলো দেওয়া হলো:
- ১০০% ওপেন সোর্স
- কোনো প্রোভাইডারের সাথে আবদ্ধ নয়। যদিও আমরা [OpenCode Zen](https://opencode.ai/zen) এর মাধ্যমে মডেলসমূহ ব্যবহারের পরামর্শ দিই, OpenCode ক্লড (Claude), ওপেনএআই (OpenAI), গুগল (Google), অথবা লোকাল মডেলগুলোর সাথেও ব্যবহার করা যেতে পারে। যেমন যেমন মডেলগুলো উন্নত হবে, তাদের মধ্যকার পার্থক্য কমে আসবে এবং দামও কমবে, তাই প্রোভাইডার-অজ্ঞাস্টিক হওয়া খুবই গুরুত্বপূর্ণ।
- আউট-অফ-দ্য-বক্স LSP সাপোর্ট
- TUI এর উপর ফোকাস। OpenCode নিওভিম (neovim) ব্যবহারকারী এবং [terminal.shop](https://terminal.shop) এর নির্মাতাদের দ্বারা তৈরি; আমরা টার্মিনালে কী কী সম্ভব তার সীমাবদ্ধতা ছাড়িয়ে যাওয়ার চেষ্টা করছি।
- ক্লায়েন্ট/সার্ভার আর্কিটেকচার। এটি যেমন OpenCode কে আপনার কম্পিউটারে চালানোর সুযোগ দেয়, তেমনি আপনি মোবাইল অ্যাপ থেকে রিমোটলি এটি নিয়ন্ত্রণ করতে পারবেন, অর্থাৎ TUI ফ্রন্টএন্ড কেবল সম্ভাব্য ক্লায়েন্টগুলোর মধ্যে একটি।
---
**আমাদের কমিউনিটিতে যুক্ত হোন** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
-12
View File
@@ -124,18 +124,6 @@ Se você tem interesse em contribuir com o OpenCode, leia os [contributing docs]
Se você estiver trabalhando em um projeto relacionado ao OpenCode e estiver usando "opencode" como parte do nome (por exemplo, "opencode-dashboard" ou "opencode-mobile"), adicione uma nota no README para deixar claro que não foi construído pela equipe do OpenCode e não é afiliado a nós de nenhuma forma.
### FAQ
#### Como isso é diferente do Claude Code?
É muito parecido com o Claude Code em termos de capacidade. Aqui estão as principais diferenças:
- 100% open source
- Não está acoplado a nenhum provedor. Embora recomendemos os modelos que oferecemos pelo [OpenCode Zen](https://opencode.ai/zen); o OpenCode pode ser usado com Claude, OpenAI, Google ou até modelos locais. À medida que os modelos evoluem, as diferenças diminuem e os preços caem, então ser provider-agnostic é importante.
- Suporte a LSP pronto para uso
- Foco em TUI. O OpenCode é construído por usuários de neovim e pelos criadores do [terminal.shop](https://terminal.shop); vamos levar ao limite o que é possível no terminal.
- Arquitetura cliente/servidor. Isso, por exemplo, permite executar o OpenCode no seu computador enquanto você o controla remotamente por um aplicativo mobile. Isso significa que o frontend TUI é apenas um dos possíveis clientes.
---
**Junte-se à nossa comunidade** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
-12
View File
@@ -124,18 +124,6 @@ Ako želiš doprinositi OpenCode-u, pročitaj [upute za doprinošenje](./CONTRIB
Ako radiš na projektu koji je povezan s OpenCode-om i koristi "opencode" kao dio naziva, npr. "opencode-dashboard" ili "opencode-mobile", dodaj napomenu u svoj README da projekat nije napravio OpenCode tim i da nije povezan s nama.
### FAQ
#### Po čemu se razlikuje od Claude Code-a?
Po mogućnostima je vrlo sličan Claude Code-u. Ključne razlike su:
- 100% open source
- Nije vezan za jednog provajdera. Iako preporučujemo modele koje nudimo kroz [OpenCode Zen](https://opencode.ai/zen), OpenCode možeš koristiti s Claude, OpenAI, Google ili čak lokalnim modelima. Kako modeli napreduju, razlike među njima će se smanjivati, a cijene padati, zato je nezavisnost od provajdera važna.
- LSP podrška odmah po instalaciji
- Fokus na TUI. OpenCode grade neovim korisnici i kreatori [terminal.shop](https://terminal.shop); pomjeraćemo granice onoga što je moguće u terminalu.
- Klijent/server arhitektura. To, recimo, omogućava da OpenCode radi na tvom računaru dok ga daljinski koristiš iz mobilne aplikacije, što znači da je TUI frontend samo jedan od mogućih klijenata.
---
**Pridruži se našoj zajednici** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
-12
View File
@@ -124,18 +124,6 @@ Hvis du vil bidrage til OpenCode, så læs vores [contributing docs](./CONTRIBUT
Hvis du arbejder på et projekt der er relateret til OpenCode og bruger "opencode" som en del af navnet; f.eks. "opencode-dashboard" eller "opencode-mobile", så tilføj en note i din README, der tydeliggør at projektet ikke er bygget af OpenCode-teamet og ikke er tilknyttet os på nogen måde.
### FAQ
#### Hvordan adskiller dette sig fra Claude Code?
Det minder meget om Claude Code i forhold til funktionalitet. Her er de vigtigste forskelle:
- 100% open source
- Ikke låst til en udbyder. Selvom vi anbefaler modellerne via [OpenCode Zen](https://opencode.ai/zen); kan OpenCode bruges med Claude, OpenAI, Google eller endda lokale modeller. Efterhånden som modeller udvikler sig vil forskellene mindskes og priserne falde, så det er vigtigt at være provider-agnostic.
- LSP-support out of the box
- Fokus på TUI. OpenCode er bygget af neovim-brugere og skaberne af [terminal.shop](https://terminal.shop); vi vil skubbe grænserne for hvad der er muligt i terminalen.
- Klient/server-arkitektur. Det kan f.eks. lade OpenCode køre på din computer, mens du styrer den eksternt fra en mobilapp. Det betyder at TUI-frontend'en kun er en af de mulige clients.
---
**Bliv en del af vores community** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
-12
View File
@@ -124,18 +124,6 @@ Wenn du zu OpenCode beitragen möchtest, lies bitte unsere [Contributing Docs](.
Wenn du an einem Projekt arbeitest, das mit OpenCode zusammenhängt und "opencode" als Teil seines Namens verwendet (z.B. "opencode-dashboard" oder "opencode-mobile"), füge bitte einen Hinweis in deine README ein, dass es nicht vom OpenCode-Team gebaut wird und nicht in irgendeiner Weise mit uns verbunden ist.
### FAQ
#### Worin unterscheidet sich das von Claude Code?
In Bezug auf die Fähigkeiten ist es Claude Code sehr ähnlich. Hier sind die wichtigsten Unterschiede:
- 100% open source
- Nicht an einen Anbieter gekoppelt. Wir empfehlen die Modelle aus [OpenCode Zen](https://opencode.ai/zen); OpenCode kann aber auch mit Claude, OpenAI, Google oder sogar lokalen Modellen genutzt werden. Mit der Weiterentwicklung der Modelle werden die Unterschiede kleiner und die Preise sinken, deshalb ist Provider-Unabhängigkeit wichtig.
- LSP-Unterstützung direkt nach dem Start
- Fokus auf TUI. OpenCode wird von Neovim-Nutzern und den Machern von [terminal.shop](https://terminal.shop) gebaut; wir treiben die Grenzen dessen, was im Terminal möglich ist.
- Client/Server-Architektur. Das ermöglicht z.B., OpenCode auf deinem Computer laufen zu lassen, während du es von einer mobilen App aus fernsteuerst. Das TUI-Frontend ist nur einer der möglichen Clients.
---
**Tritt unserer Community bei** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+9 -21
View File
@@ -97,20 +97,20 @@ OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bas
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
```
### Agents
### Agentes
OpenCode incluye dos agents integrados que puedes alternar con la tecla `Tab`.
OpenCode incluye dos agentes integrados que puedes alternar con la tecla `Tab`.
- **build** - Por defecto, agent con acceso completo para trabajo de desarrollo
- **plan** - Agent de solo lectura para análisis y exploración de código
- Niega ediciones de archivos por defecto
- **build** - Por defecto, agente con acceso completo para tareas de desarrollo
- **plan** - Agente de solo lectura para análisis y exploración de código
- Deniega ediciones de archivos por defecto
- Pide permiso antes de ejecutar comandos bash
- Ideal para explorar codebases desconocidas o planificar cambios
Además, incluye un subagent **general** para búsquedas complejas y tareas de varios pasos.
Además, incluye un subagente **general** para búsquedas complejas y tareas de varios pasos.
Se usa internamente y se puede invocar con `@general` en los mensajes.
Más información sobre [agents](https://opencode.ai/docs/agents).
Más información sobre [agentes](https://opencode.ai/docs/agents).
### Documentación
@@ -120,21 +120,9 @@ Para más información sobre cómo configurar OpenCode, [**ve a nuestra document
Si te interesa contribuir a OpenCode, lee nuestras [docs de contribución](./CONTRIBUTING.md) antes de enviar un pull request.
### Construyendo sobre OpenCode
### Proyectos basados en OpenCode
Si estás trabajando en un proyecto relacionado con OpenCode y usas "opencode" como parte del nombre; por ejemplo, "opencode-dashboard" u "opencode-mobile", agrega una nota en tu README para aclarar que no está construido por el equipo de OpenCode y que no está afiliado con nosotros de ninguna manera.
### FAQ
#### ¿En qué se diferencia de Claude Code?
Es muy similar a Claude Code en cuanto a capacidades. Estas son las diferencias clave:
- 100% open source
- No está acoplado a ningún proveedor. Aunque recomendamos los modelos que ofrecemos a través de [OpenCode Zen](https://opencode.ai/zen); OpenCode se puede usar con Claude, OpenAI, Google o incluso modelos locales. A medida que evolucionan los modelos, las brechas se cerrarán y los precios bajarán, por lo que ser agnóstico al proveedor es importante.
- Soporte LSP listo para usar
- Un enfoque en la TUI. OpenCode está construido por usuarios de neovim y los creadores de [terminal.shop](https://terminal.shop); vamos a empujar los límites de lo que es posible en la terminal.
- Arquitectura cliente/servidor. Esto, por ejemplo, permite ejecutar OpenCode en tu computadora mientras lo controlas de forma remota desde una app móvil. Esto significa que el frontend TUI es solo uno de los posibles clientes.
Si estás trabajando en un proyecto basado en OpenCode y usas "opencode" como parte del nombre, por ejemplo, "opencode-dashboard" u "opencode-mobile", agrega una nota en tu README para aclarar que no está hecho por el equipo de OpenCode y que no está afiliado con nosotros de ninguna manera.
---
-12
View File
@@ -124,18 +124,6 @@ Si vous souhaitez contribuer à OpenCode, lisez nos [docs de contribution](./CON
Si vous travaillez sur un projet lié à OpenCode et que vous utilisez "opencode" dans le nom du projet (par exemple, "opencode-dashboard" ou "opencode-mobile"), ajoutez une note dans votre README pour préciser qu'il n'est pas construit par l'équipe OpenCode et qu'il n'est pas affilié à nous.
### FAQ
#### En quoi est-ce différent de Claude Code ?
C'est très similaire à Claude Code en termes de capacités. Voici les principales différences :
- 100% open source
- Pas couplé à un fournisseur. Nous recommandons les modèles proposés via [OpenCode Zen](https://opencode.ai/zen) ; OpenCode peut être utilisé avec Claude, OpenAI, Google ou même des modèles locaux. Au fur et à mesure que les modèles évoluent, les écarts se réduiront et les prix baisseront, donc être agnostique au fournisseur est important.
- Support LSP prêt à l'emploi
- Un focus sur la TUI. OpenCode est construit par des utilisateurs de neovim et les créateurs de [terminal.shop](https://terminal.shop) ; nous allons repousser les limites de ce qui est possible dans le terminal.
- Architecture client/serveur. Cela permet par exemple de faire tourner OpenCode sur votre ordinateur tout en le pilotant à distance depuis une application mobile. Cela signifie que la TUI n'est qu'un des clients possibles.
---
**Rejoignez notre communauté** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
-12
View File
@@ -124,18 +124,6 @@ XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
Εάν εργάζεσαι σε ένα έργο σχετικό με το OpenCode και χρησιμοποιείτε το "opencode" ως μέρος του ονόματός του, για παράδειγμα "opencode-dashboard" ή "opencode-mobile", πρόσθεσε μια σημείωση στο README σας για να διευκρινίσεις ότι δεν είναι κατασκευασμένο από την ομάδα του OpenCode και δεν έχει καμία σχέση με εμάς.
### Συχνές Ερωτήσεις
#### Πώς διαφέρει αυτό από το Claude Code;
Είναι πολύ παρόμοιο με το Claude Code ως προς τις δυνατότητες. Ακολουθούν οι βασικές διαφορές:
- 100% ανοιχτού κώδικα
- Δεν είναι συνδεδεμένο με κανέναν πάροχο. Αν και συνιστούμε τα μοντέλα που παρέχουμε μέσω του [OpenCode Zen](https://opencode.ai/zen), το OpenCode μπορεί να χρησιμοποιηθεί με Claude, OpenAI, Google, ή ακόμα και τοπικά μοντέλα. Καθώς τα μοντέλα εξελίσσονται, τα κενά μεταξύ τους θα κλείσουν και οι τιμές θα μειωθούν, οπότε είναι σημαντικό να είσαι ανεξάρτητος από τον πάροχο.
- Out-of-the-box υποστήριξη LSP
- Εστίαση στο TUI. Το OpenCode είναι κατασκευασμένο από χρήστες που χρησιμοποιούν neovim και τους δημιουργούς του [terminal.shop](https://terminal.shop)· θα εξαντλήσουμε τα όρια του τι είναι δυνατό στο terminal.
- Αρχιτεκτονική client/server. Αυτό, για παράδειγμα, μπορεί να επιτρέψει στο OpenCode να τρέχει στον υπολογιστή σου ενώ το χειρίζεσαι εξ αποστάσεως από μια εφαρμογή κινητού, που σημαίνει ότι το TUI frontend είναι μόνο ένας από τους πιθανούς clients.
---
**Γίνε μέλος της κοινότητάς μας** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
-12
View File
@@ -124,18 +124,6 @@ Se sei interessato a contribuire a OpenCode, leggi la nostra [guida alla contrib
Se stai lavorando a un progetto correlato a OpenCode e che utilizza “opencode” come parte del nome (ad esempio “opencode-dashboard” o “opencode-mobile”), aggiungi una nota nel tuo README per chiarire che non è sviluppato dal team OpenCode e che non è affiliato in alcun modo con noi.
### FAQ
#### In cosa è diverso da Claude Code?
È molto simile a Claude Code in termini di funzionalità. Ecco le principali differenze:
- 100% open source
- Non è legato a nessun provider. Anche se consigliamo i modelli forniti tramite [OpenCode Zen](https://opencode.ai/zen), OpenCode può essere utilizzato con Claude, OpenAI, Google o persino modelli locali. Con levoluzione dei modelli, le differenze tra di essi si ridurranno e i prezzi scenderanno, quindi essere indipendenti dal provider è importante.
- Supporto LSP pronto alluso
- Forte attenzione alla TUI. OpenCode è sviluppato da utenti neovim e dai creatori di [terminal.shop](https://terminal.shop); spingeremo al limite ciò che è possibile fare nel terminale.
- Architettura client/server. Questo, ad esempio, permette a OpenCode di girare sul tuo computer mentre lo controlli da remoto tramite unapp mobile. La frontend TUI è quindi solo uno dei possibili client.
---
**Unisciti alla nostra community** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
-12
View File
@@ -124,18 +124,6 @@ OpenCode に貢献したい場合は、Pull Request を送る前に [contributin
OpenCode に関連するプロジェクトで、名前に "opencode"(例: "opencode-dashboard" や "opencode-mobile")を含める場合は、そのプロジェクトが OpenCode チームによって作られたものではなく、いかなる形でも関係がないことを README に明記してください。
### FAQ
#### Claude Code との違いは?
機能面では Claude Code と非常に似ています。主な違いは次のとおりです。
- 100% オープンソース
- 特定のプロバイダーに依存しません。[OpenCode Zen](https://opencode.ai/zen) で提供しているモデルを推奨しますが、OpenCode は Claude、OpenAI、Google、またはローカルモデルでも利用できます。モデルが進化すると差は縮まり価格も下がるため、provider-agnostic であることが重要です。
- そのまま使える LSP サポート
- TUI にフォーカス。OpenCode は neovim ユーザーと [terminal.shop](https://terminal.shop) の制作者によって作られており、ターミナルで可能なことの限界を押し広げます。
- クライアント/サーバー構成。例えば OpenCode をあなたのPCで動かし、モバイルアプリからリモート操作できます。TUI フロントエンドは複数あるクライアントの1つにすぎません。
---
**コミュニティに参加** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
-12
View File
@@ -124,18 +124,6 @@ OpenCode 에 기여하고 싶다면, Pull Request 를 제출하기 전에 [contr
OpenCode 와 관련된 프로젝트를 진행하면서 이름에 "opencode"(예: "opencode-dashboard" 또는 "opencode-mobile") 를 포함한다면, README 에 해당 프로젝트가 OpenCode 팀이 만든 것이 아니며 어떤 방식으로도 우리와 제휴되어 있지 않다는 점을 명시해 주세요.
### FAQ
#### Claude Code 와는 무엇이 다른가요?
기능 면에서는 Claude Code 와 매우 유사합니다. 주요 차이점은 다음과 같습니다.
- 100% 오픈 소스
- 특정 제공자에 묶여 있지 않습니다. [OpenCode Zen](https://opencode.ai/zen) 을 통해 제공하는 모델을 권장하지만, OpenCode 는 Claude, OpenAI, Google 또는 로컬 모델과도 사용할 수 있습니다. 모델이 발전하면서 격차는 줄고 가격은 내려가므로 provider-agnostic 인 것이 중요합니다.
- 기본으로 제공되는 LSP 지원
- TUI 에 집중. OpenCode 는 neovim 사용자와 [terminal.shop](https://terminal.shop) 제작자가 만들었으며, 터미널에서 가능한 것의 한계를 밀어붙입니다.
- 클라이언트/서버 아키텍처. 예를 들어 OpenCode 를 내 컴퓨터에서 실행하면서 모바일 앱으로 원격 조작할 수 있습니다. 즉, TUI 프런트엔드는 가능한 여러 클라이언트 중 하나일 뿐입니다.
---
**커뮤니티에 참여하기** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
-12
View File
@@ -124,18 +124,6 @@ If you're interested in contributing to OpenCode, please read our [contributing
If you are working on a project that's related to OpenCode and is using "opencode" as part of its name, for example "opencode-dashboard" or "opencode-mobile", please add a note to your README to clarify that it is not built by the OpenCode team and is not affiliated with us in any way.
### FAQ
#### How is this different from Claude Code?
It's very similar to Claude Code in terms of capability. Here are the key differences:
- 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
- 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.
---
**Join our community** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
-12
View File
@@ -124,18 +124,6 @@ Hvis du vil bidra til OpenCode, les [contributing docs](./CONTRIBUTING.md) før
Hvis du jobber med et prosjekt som er relatert til OpenCode og bruker "opencode" som en del av navnet; for eksempel "opencode-dashboard" eller "opencode-mobile", legg inn en merknad i README som presiserer at det ikke er bygget av OpenCode-teamet og ikke er tilknyttet oss på noen måte.
### FAQ
#### Hvordan er dette forskjellig fra Claude Code?
Det er veldig likt Claude Code når det gjelder funksjonalitet. Her er de viktigste forskjellene:
- 100% open source
- Ikke knyttet til en bestemt leverandør. Selv om vi anbefaler modellene vi tilbyr gjennom [OpenCode Zen](https://opencode.ai/zen); kan OpenCode brukes med Claude, OpenAI, Google eller til og med lokale modeller. Etter hvert som modellene utvikler seg vil gapene lukkes og prisene gå ned, så det er viktig å være provider-agnostic.
- LSP-støtte rett ut av boksen
- Fokus på TUI. OpenCode er bygget av neovim-brukere og skaperne av [terminal.shop](https://terminal.shop); vi kommer til å presse grensene for hva som er mulig i terminalen.
- Klient/server-arkitektur. Dette kan for eksempel la OpenCode kjøre på maskinen din, mens du styrer den eksternt fra en mobilapp. Det betyr at TUI-frontend'en bare er en av de mulige klientene.
---
**Bli med i fellesskapet** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
-12
View File
@@ -124,18 +124,6 @@ Jeśli chcesz współtworzyć OpenCode, przeczytaj [contributing docs](./CONTRIB
Jeśli pracujesz nad projektem związanym z OpenCode i używasz "opencode" jako części nazwy (na przykład "opencode-dashboard" lub "opencode-mobile"), dodaj proszę notatkę do swojego README, aby wyjaśnić, że projekt nie jest tworzony przez zespół OpenCode i nie jest z nami w żaden sposób powiązany.
### FAQ
#### Czym to się różni od Claude Code?
Jest bardzo podobne do Claude Code pod względem możliwości. Oto kluczowe różnice:
- 100% open source
- Niezależne od dostawcy. Chociaż polecamy modele oferowane przez [OpenCode Zen](https://opencode.ai/zen); OpenCode może być używany z Claude, OpenAI, Google, a nawet z modelami lokalnymi. W miarę jak modele ewoluują, różnice będą się zmniejszać, a ceny spadać, więc ważna jest niezależność od dostawcy.
- Wbudowane wsparcie LSP
- Skupienie na TUI. OpenCode jest budowany przez użytkowników neovim i twórców [terminal.shop](https://terminal.shop); przesuwamy granice tego, co jest możliwe w terminalu.
- Architektura klient/serwer. Pozwala np. uruchomić OpenCode na twoim komputerze, a sterować nim zdalnie z aplikacji mobilnej. To znaczy, że frontend TUI jest tylko jednym z możliwych klientów.
---
**Dołącz do naszej społeczności** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
-12
View File
@@ -124,18 +124,6 @@ XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
Если вы делаете проект, связанный с OpenCode, и используете "opencode" как часть имени (например, "opencode-dashboard" или "opencode-mobile"), добавьте примечание в README, чтобы уточнить, что проект не создан командой OpenCode и не аффилирован с нами.
### FAQ
#### Чем это отличается от Claude Code?
По возможностям это очень похоже на Claude Code. Вот ключевые отличия:
- 100% open source
- Не привязано к одному провайдеру. Мы рекомендуем модели из [OpenCode Zen](https://opencode.ai/zen); но OpenCode можно использовать с Claude, OpenAI, Google или даже локальными моделями. По мере развития моделей разрыв будет сокращаться, а цены падать, поэтому важна независимость от провайдера.
- Поддержка LSP из коробки
- Фокус на TUI. OpenCode построен пользователями neovim и создателями [terminal.shop](https://terminal.shop); мы будем раздвигать границы того, что возможно в терминале.
- Архитектура клиент/сервер. Например, это позволяет запускать OpenCode на вашем компьютере, а управлять им удаленно из мобильного приложения. Это значит, что TUI-фронтенд - лишь один из возможных клиентов.
---
**Присоединяйтесь к нашему сообществу** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
-12
View File
@@ -124,18 +124,6 @@ OpenCode รวมเอเจนต์ในตัวสองตัวที
หากคุณทำงานในโปรเจกต์ที่เกี่ยวข้องกับ OpenCode และใช้ "opencode" เป็นส่วนหนึ่งของชื่อ เช่น "opencode-dashboard" หรือ "opencode-mobile" โปรดเพิ่มหมายเหตุใน README ของคุณเพื่อชี้แจงว่าไม่ได้สร้างโดยทีม OpenCode และไม่ได้เกี่ยวข้องกับเราในทางใด
### คำถามที่พบบ่อย
#### ต่างจาก Claude Code อย่างไร?
คล้ายกับ Claude Code มากในแง่ความสามารถ นี่คือความแตกต่างหลัก:
- โอเพนซอร์ส 100%
- ไม่ผูกมัดกับผู้ให้บริการใดๆ แม้ว่าเราจะแนะนำโมเดลที่เราจัดหาให้ผ่าน [OpenCode Zen](https://opencode.ai/zen) OpenCode สามารถใช้กับ Claude, OpenAI, Google หรือแม้กระทั่งโมเดลในเครื่องได้ เมื่อโมเดลพัฒนาช่องว่างระหว่างพวกมันจะปิดลงและราคาจะลดลง ดังนั้นการไม่ผูกมัดกับผู้ให้บริการจึงสำคัญ
- รองรับ LSP ใช้งานได้ทันทีหลังการติดตั้งโดยไม่ต้องปรับแต่งหรือเปลี่ยนแปลงฟังก์ชันการทำงานใด ๆ
- เน้นที่ TUI OpenCode สร้างโดยผู้ใช้ neovim และผู้สร้าง [terminal.shop](https://terminal.shop) เราจะผลักดันขีดจำกัดของสิ่งที่เป็นไปได้ในเทอร์มินัล
- สถาปัตยกรรมไคลเอนต์/เซิร์ฟเวอร์ ตัวอย่างเช่น อาจอนุญาตให้ OpenCode ทำงานบนคอมพิวเตอร์ของคุณ ในขณะที่คุณสามารถขับเคลื่อนจากระยะไกลผ่านแอปมือถือ หมายความว่า TUI frontend เป็นหนึ่งในไคลเอนต์ที่เป็นไปได้เท่านั้น
---
**ร่วมชุมชนของเรา** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
-12
View File
@@ -124,18 +124,6 @@ OpenCode'a katkıda bulunmak istiyorsanız, lütfen bir pull request göndermede
OpenCode ile ilgili bir proje üzerinde çalışıyorsanız ve projenizin adının bir parçası olarak "opencode" kullanıyorsanız (örneğin, "opencode-dashboard" veya "opencode-mobile"), lütfen README dosyanıza projenin OpenCode ekibi tarafından geliştirilmediğini ve bizimle hiçbir şekilde bağlantılı olmadığını belirten bir not ekleyin.
### SSS
#### Bu Claude Code'dan nasıl farklı?
Yetenekler açısından Claude Code'a çok benzer. İşte temel farklar:
- %100 açık kaynak
- Herhangi bir sağlayıcıya bağlı değil. [OpenCode Zen](https://opencode.ai/zen) üzerinden sunduğumuz modelleri önermekle birlikte; OpenCode, Claude, OpenAI, Google veya hatta yerel modellerle kullanılabilir. Modeller geliştikçe aralarındaki farklar kapanacak ve fiyatlar düşecek, bu nedenle sağlayıcıdan bağımsız olmak önemlidir.
- Kurulum gerektirmeyen hazır LSP desteği
- TUI odaklı yaklaşım. OpenCode, neovim kullanıcıları ve [terminal.shop](https://terminal.shop)'un geliştiricileri tarafından geliştirilmektedir; terminalde olabileceklerin sınırlarını zorlayacağız.
- İstemci/sunucu (client/server) mimarisi. Bu, örneğin OpenCode'un bilgisayarınızda çalışması ve siz onu bir mobil uygulamadan uzaktan yönetmenizi sağlar. TUI arayüzü olası istemcilerden sadece biridir.
---
**Topluluğumuza katılın** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
-12
View File
@@ -125,18 +125,6 @@ OpenCode містить два вбудовані агенти, між яким
Якщо ви працюєте над проєктом, пов'язаним з OpenCode, і використовуєте "opencode" у назві, наприклад "opencode-dashboard" або "opencode-mobile", додайте примітку до свого README.
Уточніть, що цей проєкт не створений командою OpenCode і жодним чином не афілійований із нами.
### FAQ
#### Чим це відрізняється від Claude Code?
За можливостями це дуже схоже на Claude Code. Ось ключові відмінності:
- 100% open source
- Немає прив'язки до конкретного провайдера. Ми рекомендуємо моделі, які надаємо через [OpenCode Zen](https://opencode.ai/zen), але OpenCode також працює з Claude, OpenAI, Google і навіть локальними моделями. З розвитком моделей різниця між ними зменшуватиметься, а ціни падатимуть, тому незалежність від провайдера має значення.
- Підтримка LSP з коробки
- Фокус на TUI. OpenCode створено користувачами neovim та авторами [terminal.shop](https://terminal.shop); ми й надалі розширюватимемо межі можливого в терміналі.
- Клієнт-серверна архітектура. Наприклад, це дає змогу запускати OpenCode на вашому комп'ютері й керувати ним віддалено з мобільного застосунку, тобто TUI-фронтенд - лише один із можливих клієнтів.
---
**Приєднуйтеся до нашої спільноти** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
-12
View File
@@ -124,18 +124,6 @@ Nếu bạn muốn đóng góp cho OpenCode, vui lòng đọc [tài liệu hư
Nếu bạn đang làm việc trên một dự án liên quan đến OpenCode và sử dụng "opencode" như một phần của tên dự án, ví dụ "opencode-dashboard" hoặc "opencode-mobile", vui lòng thêm một ghi chú vào README của bạn để làm rõ rằng dự án đó không được xây dựng bởi đội ngũ OpenCode và không liên kết với chúng tôi dưới bất kỳ hình thức nào.
### Các câu hỏi thường gặp (FAQ)
#### OpenCode khác biệt thế nào so với Claude Code?
Về mặt tính năng, nó rất giống Claude Code. Dưới đây là những điểm khác biệt chính:
- 100% mã nguồn mở
- Không bị ràng buộc với bất kỳ nhà cung cấp nào. Mặc dù chúng tôi khuyên dùng các mô hình được cung cấp qua [OpenCode Zen](https://opencode.ai/zen), OpenCode có thể được sử dụng với Claude, OpenAI, Google, hoặc thậm chí các mô hình chạy cục bộ. Khi các mô hình phát triển, khoảng cách giữa chúng sẽ thu hẹp lại và giá cả sẽ giảm, vì vậy việc không phụ thuộc vào nhà cung cấp là rất quan trọng.
- Hỗ trợ LSP ngay từ đầu
- Tập trung vào TUI (Giao diện người dùng dòng lệnh). OpenCode được xây dựng bởi những người dùng neovim và đội ngũ tạo ra [terminal.shop](https://terminal.shop); chúng tôi sẽ đẩy giới hạn của những gì có thể làm được trên terminal lên mức tối đa.
- Kiến trúc client/server. Chẳng hạn, điều này cho phép OpenCode chạy trên máy tính của bạn trong khi bạn điều khiển nó từ xa qua một ứng dụng di động, nghĩa là frontend TUI chỉ là một trong những client có thể dùng.
---
**Tham gia cộng đồng của chúng tôi** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
-12
View File
@@ -123,18 +123,6 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
如果你在项目名中使用了 “opencode”(如 “opencode-dashboard” 或 “opencode-mobile”),请在 README 里注明该项目不是 OpenCode 团队官方开发,且不存在隶属关系。
### 常见问题 (FAQ)
#### 这和 Claude Code 有什么不同?
功能上很相似,关键差异:
- 100% 开源。
- 不绑定特定提供商。推荐使用 [OpenCode Zen](https://opencode.ai/zen) 的模型,但也可搭配 Claude、OpenAI、Google 甚至本地模型。模型迭代会缩小差异、降低成本,因此保持 provider-agnostic 很重要。
- 内置 LSP 支持。
- 聚焦终端界面 (TUI)。OpenCode 由 Neovim 爱好者和 [terminal.shop](https://terminal.shop) 的创建者打造,会持续探索终端的极限。
- 客户端/服务器架构。可在本机运行,同时用移动设备远程驱动。TUI 只是众多潜在客户端之一。
---
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
-12
View File
@@ -123,18 +123,6 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
如果您正在開發與 OpenCode 相關的專案,並在名稱中使用了 "opencode"(例如 "opencode-dashboard" 或 "opencode-mobile"),請在您的 README 中加入聲明,說明該專案並非由 OpenCode 團隊開發,且與我們沒有任何隸屬關係。
### 常見問題 (FAQ)
#### 這跟 Claude Code 有什麼不同?
在功能面上與 Claude Code 非常相似。以下是關鍵差異:
- 100% 開源。
- 不綁定特定的服務提供商。雖然我們推薦使用透過 [OpenCode Zen](https://opencode.ai/zen) 提供的模型,但 OpenCode 也可搭配 Claude, OpenAI, Google 甚至本地模型使用。隨著模型不斷演進,彼此間的差距會縮小且價格會下降,因此具備「不限廠商 (provider-agnostic)」的特性至關重要。
- 內建 LSP (語言伺服器協定) 支援。
- 專注於終端機介面 (TUI)。OpenCode 由 Neovim 愛好者與 [terminal.shop](https://terminal.shop) 的創作者打造。我們將不斷挑戰終端機介面的極限。
- 客戶端/伺服器架構 (Client/Server Architecture)。這讓 OpenCode 能夠在您的電腦上運行的同時,由行動裝置進行遠端操控。這意味著 TUI 前端只是眾多可能的客戶端之一。
---
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
+32 -32
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.15.0",
"version": "1.15.4",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -84,7 +84,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.15.0",
"version": "1.15.4",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -119,7 +119,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.15.0",
"version": "1.15.4",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -146,7 +146,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.15.0",
"version": "1.15.4",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.15.0",
"version": "1.15.4",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.15.0",
"version": "1.15.4",
"bin": {
"opencode": "./bin/opencode",
},
@@ -253,7 +253,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.15.0",
"version": "1.15.4",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -307,7 +307,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.15.0",
"version": "1.15.4",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -337,7 +337,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.15.0",
"version": "1.15.4",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -353,7 +353,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.15.0",
"version": "1.15.4",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -366,7 +366,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.15.0",
"version": "1.15.4",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -384,7 +384,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.15.0",
"version": "1.15.4",
"bin": {
"opencode": "./bin/opencode",
},
@@ -520,7 +520,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.15.0",
"version": "1.15.4",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -536,9 +536,9 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.2.10",
"@opentui/keymap": ">=0.2.10",
"@opentui/solid": ">=0.2.10",
"@opentui/core": ">=0.2.11",
"@opentui/keymap": ">=0.2.11",
"@opentui/solid": ">=0.2.11",
},
"optionalPeers": [
"@opentui/core",
@@ -558,7 +558,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.15.0",
"version": "1.15.4",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -573,7 +573,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.15.0",
"version": "1.15.4",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -608,7 +608,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.15.0",
"version": "1.15.4",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -657,7 +657,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.15.0",
"version": "1.15.4",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -721,9 +721,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.2.10",
"@opentui/keymap": "0.2.10",
"@opentui/solid": "0.2.10",
"@opentui/core": "0.2.11",
"@opentui/keymap": "0.2.11",
"@opentui/solid": "0.2.11",
"@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -1590,23 +1590,23 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.2.10", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.10", "@opentui/core-darwin-x64": "0.2.10", "@opentui/core-linux-arm64": "0.2.10", "@opentui/core-linux-x64": "0.2.10", "@opentui/core-win32-arm64": "0.2.10", "@opentui/core-win32-x64": "0.2.10" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-oviCtx0jYjc7F8X2b8+0IkQLg6WH47Nwl6CFeZo5dU0k6OpSbTbi07ZleObaiECAp+S1YLhAtVdgzHU7hBZlaw=="],
"@opentui/core": ["@opentui/core@0.2.11", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.11", "@opentui/core-darwin-x64": "0.2.11", "@opentui/core-linux-arm64": "0.2.11", "@opentui/core-linux-x64": "0.2.11", "@opentui/core-win32-arm64": "0.2.11", "@opentui/core-win32-x64": "0.2.11" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-X0zLmcDEvMrPzWYp769I7VEVb+og38vaete9tGZXu9HnJgu/paPUUplUT+6denBQccr2qx1rBYV6EtgbBpLEyw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+lbDDj42Og+UtTZEwlHhGXichmOlkxSqn0J+Jqjat5/Tt5oZykj1NZjFIQ7ZSz4Miz7EmZwgYKE2CyOmmm9MoQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-h2MXtE2Cu3XlKVoQMXthnbhleO68zGXkoh/r1Q5pCoZh6RuXqns5/94D/aZThXBWwzPuEoyarMlxxR9OqrpvHw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-5iAoA0aqMWWAQ93nh8Bb0ipwt9h+tvEFc88+YO9St43uUJ+XrXcmMj3T8wtl6dSu/SN0UoDWNaUMHUmtykiPtg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-Y0jbPClnOBTPSIy+2THG86MTqIG/jGFlOOKuw4JfCDqEjPBM3pLWIHnJb3WxHRi2LlvfyBxvrUTXWlW6JpI0QQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-EnrkxgH5K76Oi/Br1UHPZblXG5P60snmtySfnxuVaeECNZrbTkV6BV/A0WoBeWshJweGbx1D+eTF+sEEjQCi8w=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-blQyyuTaW4q/OQ3whs7Kt7GCXhBUR5EQHHDdjOqQAr0HYpohUa6sbHMbiBcX2Ehc9ZWwtiaOoWiyZ5YXy2SAvg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.10", "", { "os": "linux", "cpu": "x64" }, "sha512-fI+r3kCPqIxsWwPVGpKUQy4zHK8y+jkDRCwa3UbaUy48RQ44jMuf2RhVhmi4xmCvSc8UPJBbYsw1tLuh9kmXjg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-0nEB5+MgzQRYiVcQd1vHXPWNPWGh4JEmQTJKyG3OHnTzPaJ1FVSQ/V71ECyRSl3ymY3F+U0eW9cFgw1hCieK2w=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-8F4z2hIRgkVWcr6CMVeJ9N4+1rmURPt2Pq2GBPko8ch6rxHR+a//KD1MfphyuLTHBS1tJ4vfZSWSoiaESImtrA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-+KKH77fzm0qF8py9G2pU32DzB1bAgDMfBajrs7gKL5NtSEnknrwfh7hIs/tq41aF6j9zvIzgtykByh26tcjFog=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.10", "", { "os": "win32", "cpu": "x64" }, "sha512-Ki+qNBlIFW5K2wcG/RHrlPp7yEQKXeiNX3mlje25iwX62Ac5w391HBpOmUjbPoq20McPyDRnhbLfbXQSPtickg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.11", "", { "os": "win32", "cpu": "x64" }, "sha512-dMmb9DX0W0HWadLdgciMbonqIc1xdcKiVmaQSYxw5eGCzFRPZIOrKHByesP+2ipkMuLx85W/MJUFal/lW8XSNg=="],
"@opentui/keymap": ["@opentui/keymap@0.2.10", "", { "dependencies": { "@opentui/core": "0.2.10" }, "peerDependencies": { "@opentui/react": "0.2.10", "@opentui/solid": "0.2.10", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-80fU3Lr/98sNIpVYd8PApAeQw8A8D9BemyOGi6jGvTQCl0rxKgvaVBviDRGKxl1INTVjZy9By8UPncc2KJOuWQ=="],
"@opentui/keymap": ["@opentui/keymap@0.2.11", "", { "dependencies": { "@opentui/core": "0.2.11" }, "peerDependencies": { "@opentui/react": "0.2.11", "@opentui/solid": "0.2.11", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-pCrJrY3mTuXdDaaRneId1JsJCtGE+7prTtWihzOLZzVJTJYyYtT38gMI7MpyAoloVDfEL5cTe8C+v7wv+IYREw=="],
"@opentui/solid": ["@opentui/solid@0.2.10", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.10", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-+4/MB90yIQiPwg8Y4wY092yva9BvRTsJeeeEO3e2H7P8k8zxYk4G9bzuhqYLxA9mTVQ+zVDlrmFoPQhT7vpIRw=="],
"@opentui/solid": ["@opentui/solid@0.2.11", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.11", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-M3WHxBFORHVE0yqMJYpi9PfjXWlnRTw/LYuBhZaJv0HTo+zTs60P/ukGcwnHDWnMpTGf3BH9x0Yi2dIqjHRY6Q=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
+7 -10
View File
@@ -37,16 +37,14 @@
node_modules = final.callPackage ./nix/node_modules.nix {
inherit rev;
};
in
rec {
opencode = final.callPackage ./nix/opencode.nix {
inherit node_modules;
};
desktop = final.callPackage ./nix/desktop.nix {
opencode-desktop = final.callPackage ./nix/desktop.nix {
inherit opencode;
};
in
{
inherit opencode;
opencode-desktop = desktop;
};
};
@@ -56,16 +54,15 @@
node_modules = pkgs.callPackage ./nix/node_modules.nix {
inherit rev;
};
in
rec {
default = opencode;
opencode = pkgs.callPackage ./nix/opencode.nix {
inherit node_modules;
};
desktop = pkgs.callPackage ./nix/desktop.nix {
opencode-desktop = pkgs.callPackage ./nix/desktop.nix {
inherit opencode;
};
in
{
default = opencode;
inherit opencode desktop;
# Updater derivation with fakeHash - build fails and reveals correct hash
node_modules_updater = node_modules.override {
hash = pkgs.lib.fakeHash;
+29 -31
View File
@@ -46,10 +46,24 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => {
]
const failedHttpStatus = calculatedField({
name: "is_failed_http_status",
expression:
product === "go"
? `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401")), NOT(EQUALS($status, "429"))), 1, 0)`
: `IF(AND(EQUALS($status, "429"), $isFreeTier), 0, AND(GTE($status, "400"), NOT(EQUALS($status, "401"))), 1, 0)`,
expression: `
IF(
AND(
GTE($status, "400"),
NOT(EQUALS($status, "401")),
NOT(
AND(
EQUALS($status, "429"),
OR(
EQUALS($error.type, "GoUsageLimitError"),
EQUALS($error.type, "FreeUsageLimitError")
)
)
)
),
1,
0
)`,
})
return honeycomb.getQuerySpecificationOutput({
@@ -65,16 +79,15 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => {
filters,
},
],
formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 100), DIV($FAILED, $TOTAL), 0)" }],
formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 200), DIV($FAILED, $TOTAL), 0)" }],
timeRange: 900,
}).json
}
const providerHttpErrorsQuery = (product: "go" | "zen") => {
const providerHttpErrorsQuery = () => {
const filters = [
{ column: "provider", op: "exists" },
{ column: "user_agent", op: "contains", value: "opencode" },
{ column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" },
]
const successHttpStatus = calculatedField({
name: "is_success_http_status",
@@ -101,11 +114,15 @@ const providerHttpErrorsQuery = (product: "go" | "zen") => {
name: "FAILED",
column: failedProviderHttpStatus.name,
filterCombination: "AND",
filters: [...filters, { column: "event_type", op: "=", value: "llm.error" }],
filters: [
...filters,
{ column: "event_type", op: "=", value: "llm.error" },
{ column: "llm.error.code", op: "!=", value: "404" },
],
},
],
formulas: [
{ name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 50), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" },
{ name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 200), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" },
],
timeRange: 900,
}).json
@@ -215,29 +232,10 @@ new honeycomb.Trigger("LowModelTpsZen", {
],
})
new honeycomb.Trigger("IncreasedProviderHttpErrorsGo", {
name: "Increased Provider HTTP Errors [Go]",
new honeycomb.Trigger("IncreasedProviderHttpErrors", {
name: "Increased Provider HTTP Errors",
description,
queryJson: providerHttpErrorsQuery("go"),
alertType: "on_change",
frequency: 300,
thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }],
recipients: [
{
id: webhookRecipient.id,
notificationDetails: [
{
variables: [{ name: "type", value: "provider_http_errors" }],
},
],
},
],
})
new honeycomb.Trigger("IncreasedProviderHttpErrorsZen", {
name: "Increased Provider HTTP Errors [Zen]",
description,
queryJson: providerHttpErrorsQuery("zen"),
queryJson: providerHttpErrorsQuery(),
alertType: "on_change",
frequency: 300,
thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }],
+74 -73
View File
@@ -1,100 +1,101 @@
{
lib,
stdenv,
rustPlatform,
pkg-config,
cargo-tauri,
bun,
nodejs,
cargo,
rustc,
jq,
wrapGAppsHook4,
electron_41,
makeWrapper,
dbus,
glib,
gtk4,
libsoup_3,
librsvg,
libappindicator,
glib-networking,
openssl,
webkitgtk_4_1,
gst_all_1,
writableTmpDirAsHomeHook,
autoPatchelfHook,
opencode,
}:
rustPlatform.buildRustPackage (finalAttrs: {
let
electron = electron_41;
in
stdenv.mkDerivation (finalAttrs: {
pname = "opencode-desktop";
inherit (opencode)
version
src
node_modules
patches
;
cargoRoot = "packages/desktop/src-tauri";
cargoLock.lockFile = ../packages/desktop/src-tauri/Cargo.lock;
buildAndTestSubdir = finalAttrs.cargoRoot;
inherit (opencode) version src node_modules;
nativeBuildInputs = [
pkg-config
cargo-tauri.hook
bun
nodejs # for patchShebangs node_modules
cargo
rustc
jq
nodejs
makeWrapper
] ++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ];
buildInputs = lib.optionals stdenv.isLinux [
dbus
glib
gtk4
libsoup_3
librsvg
libappindicator
glib-networking
openssl
webkitgtk_4_1
gst_all_1.gstreamer
gst_all_1.gst-plugins-base
gst_all_1.gst-plugins-good
gst_all_1.gst-plugins-bad
writableTmpDirAsHomeHook
] ++ lib.optionals stdenv.hostPlatform.isLinux [
autoPatchelfHook
];
strictDeps = true;
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
(lib.getLib stdenv.cc.cc)
];
env = opencode.env // {
ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
};
# https://github.com/electron/electron/issues/31121
# mac builds use a .app bundle which doesnt have this issue
postPatch = lib.optionalString stdenv.isLinux ''
BASE_PATH=packages/desktop
FILES=(src/main/windows.ts)
for file in "''${FILES[@]}"; do
substituteInPlace $BASE_PATH/$file \
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
done
'';
preBuild = ''
cp -a ${finalAttrs.node_modules}/{node_modules,packages} .
chmod -R u+w node_modules packages
cp -r "${electron.dist}" $HOME/.electron-dist
chmod -R u+w $HOME/.electron-dist
cp -R ${finalAttrs.node_modules}/. .
patchShebangs node_modules
patchShebangs packages/desktop/node_modules
mkdir -p packages/desktop/src-tauri/sidecars
cp ${opencode}/bin/opencode packages/desktop/src-tauri/sidecars/opencode-cli-${stdenv.hostPlatform.rust.rustcTarget}
patchShebangs packages/*/node_modules
'';
# see publish-tauri job in .github/workflows/publish.yml
tauriBuildFlags = [
"--config"
"tauri.prod.conf.json"
"--no-sign" # no code signing or auto updates
buildPhase = ''
runHook preBuild
cd packages/desktop
bun run build
npx electron-builder --dir \
--config electron-builder.config.ts \
--config.mac.identity=null \
--config.electronDist="$HOME/.electron-dist"
runHook postBuild
'';
installPhase =
''
runHook preInstall
''
+ lib.optionalString stdenv.hostPlatform.isDarwin ''
mkdir -p $out/Applications
mv dist/mac*/*.app $out/Applications
makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop
''
+ lib.optionalString stdenv.hostPlatform.isLinux ''
mkdir -p $out/opt/opencode-desktop
cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop
makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
--inherit-argv0 \
--set ELECTRON_FORCE_IS_PACKAGED 1 \
--add-flags $out/opt/opencode-desktop/resources/app.asar \
--add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}"
''
+ ''
runHook postInstall
'';
autoPatchelfIgnoreMissingDeps = [
"libc.musl-x86_64.so.1"
];
# FIXME: workaround for concerns about case insensitive filesystems
# should be removed once binary is renamed or decided otherwise
# darwin output is a .app bundle so no conflict
postFixup = lib.optionalString stdenv.hostPlatform.isLinux ''
mv $out/bin/OpenCode $out/bin/opencode-desktop
sed -i 's|^Exec=OpenCode$|Exec=opencode-desktop|' $out/share/applications/OpenCode.desktop
'';
meta = {
description = "OpenCode Desktop App";
homepage = "https://opencode.ai";
license = lib.licenses.mit;
mainProgram = "opencode-desktop";
inherit (opencode.meta) platforms;
inherit (opencode.meta) homepage license platforms;
};
})
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Hw7sVV9rTm6qBMtdwfLIV2QvxvLQY5qrywXzuyYbhcs=",
"aarch64-linux": "sha256-++oXnY7YqrYt0Qv7ZISmoHliARM9qEP8FacqLxGZH1c=",
"aarch64-darwin": "sha256-kZVa0R1YbuvtTzpETqK6ddj4ISje5jBFHBdlynkhW7Q=",
"x86_64-darwin": "sha256-94eagNDa8GGJxF8BsMX2BF5Pa+QTl48lXL1+6HgEn0I="
"x86_64-linux": "sha256-Ucvyzyq+oYvWglkeowSvb0LgDzkAvaSdq0CdA6jgN6U=",
"aarch64-linux": "sha256-SERwZvvN6P8/OwNolHmC0KU9H5laVQm+FD/NNKauZA8=",
"aarch64-darwin": "sha256-I1ABwMHkTAntlYyg43w0cW8iPYfZa9MT0In2C7plB5g=",
"x86_64-darwin": "sha256-degJTL0RG7QQO8/USgIF//ya7oNmwChTmAoJcpXbIp0="
}
}
+3 -2
View File
@@ -40,7 +40,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
env.MODELS_DEV_API_JSON = "${models-dev}/dist/_api.json";
env.OPENCODE_DISABLE_MODELS_FETCH = true;
env.OPENCODE_VERSION = finalAttrs.version;
env.OPENCODE_CHANNEL = "local";
env.OPENCODE_CHANNEL = "prod";
buildPhase = ''
runHook preBuild
@@ -89,11 +89,12 @@ stdenvNoCC.mkDerivation (finalAttrs: {
passthru = {
jsonschema = "${placeholder "out"}/share/opencode/schema.json";
env = finalAttrs.env;
};
meta = {
description = "The open source coding agent";
homepage = "https://opencode.ai/";
homepage = "https://opencode.ai";
license = lib.licenses.mit;
mainProgram = "opencode";
inherit (node_modules.meta) platforms;
+3 -3
View File
@@ -35,9 +35,9 @@
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.10",
"@opentui/keymap": "0.2.10",
"@opentui/solid": "0.2.10",
"@opentui/core": "0.2.11",
"@opentui/keymap": "0.2.11",
"@opentui/solid": "0.2.11",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.15.0",
"version": "1.15.4",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.15.0",
"version": "1.15.4",
"type": "module",
"license": "MIT",
"scripts": {
@@ -161,7 +161,9 @@ export async function POST(input: APIEvent) {
})
if (userEmail) {
if (coupon === LiteData.firstMonth100Coupon) {
if (coupon === LiteData.firstMonth50Coupon) {
await Billing.redeemCoupon(userEmail, "GO1MONTH50")
} else if (coupon === LiteData.firstMonth100Coupon) {
await Billing.redeemCoupon(userEmail, "GOFREEMONTH")
} else if (coupon === LiteData.threeMonths100Coupon) {
await Billing.redeemCoupon(userEmail, "GO3MONTHS100")
@@ -10,8 +10,11 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
const dict = i18n(localeFromRequest(request))
const limits = Subscription.getFreeLimits()
const dailyLimit = rateLimit ?? limits.dailyRequests
const isDefaultModel = !rateLimit
const headersExist = Object.entries(limits.checkHeaders).every(
([name, value]) => request.headers.get(name)?.toLowerCase().includes(value) ?? false,
)
const dailyLimit = !headersExist ? limits.dailyRequestsFallback : (rateLimit ?? limits.dailyRequests)
const isDefaultModel = headersExist && !rateLimit
const ip = !rawIp.length ? "unknown" : rawIp
const now = Date.now()
@@ -0,0 +1 @@
ALTER TABLE `coupon` MODIFY COLUMN `type` enum('BUILDATHON','GO1MONTH50','GOFREEMONTH','GO3MONTHS100','GO6MONTHS100','GO12MONTHS100') NOT NULL;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.15.0",
"version": "1.15.4",
"private": true,
"type": "module",
"license": "MIT",
@@ -10,7 +10,7 @@ if (!stage) throw new Error("Stage is required")
const root = path.resolve(process.cwd(), "..", "..", "..")
// read the secret
const ret = await $`bun sst secret list --stage frank`.cwd(root).text()
const ret = await $`bun sst secret list --fallback`.cwd(root).text()
const lines = ret.split("\n")
const value = lines.find((line) => line.startsWith("ZEN_LIMITS"))?.split("=")[1]
if (!value) throw new Error("ZEN_LIMITS not found")
@@ -6,7 +6,7 @@ import os from "os"
import { Subscription } from "../src/subscription"
const root = path.resolve(process.cwd(), "..", "..", "..")
const secrets = await $`bun sst secret list --stage frank`.cwd(root).text()
const secrets = await $`bun sst secret list --fallback`.cwd(root).text()
// read value
const lines = secrets.split("\n")
@@ -25,4 +25,6 @@ const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
Subscription.validate(JSON.parse(newValue))
// update the secret
await $`bun sst secret set ZEN_LIMITS ${newValue} --stage frank`.cwd(root)
const envFile = Bun.file(path.join(os.tmpdir(), `limits-${Date.now()}.env`))
await envFile.write(`ZEN_LIMITS="${newValue.replace(/"/g, '\\"')}"`)
await $`bun sst secret load ${envFile.name} --fallback`.cwd(root)
+41 -33
View File
@@ -156,33 +156,32 @@ export namespace Billing {
}
export const redeemCoupon = async (email: string, type: (typeof CouponType)[number]) => {
const coupon = await Database.use((tx) =>
tx
.select()
.from(CouponTable)
.where(and(eq(CouponTable.email, email), eq(CouponTable.type, type)))
.then((rows) => rows[0]),
)
if (!coupon) throw new Error("Invalid coupon code")
if (coupon.timeRedeemed) throw new Error("Coupon already redeemed")
// validate coupon type
await (async () => {
if (type === "GO1MONTH50") return
const coupon = await Database.use((tx) =>
tx
.select()
.from(CouponTable)
.where(and(eq(CouponTable.email, email), eq(CouponTable.type, type)))
.then((rows) => rows[0]),
)
if (!coupon) throw new Error("Invalid coupon code")
if (coupon.timeRedeemed) throw new Error("Coupon already redeemed")
})()
// handle coupon type
if (type === "BUILDATHON") await grantCredit(Actor.workspace(), 500)
await Database.use((tx) =>
tx
.update(CouponTable)
.set({ timeRedeemed: sql`now()` })
.where(and(eq(CouponTable.email, email), eq(CouponTable.type, type))),
)
}
export const getCoupons = async (email: string) => {
return await Database.use((tx) =>
tx
.select({ type: CouponTable.type, timeRedeemed: CouponTable.timeRedeemed })
.from(CouponTable)
.where(and(eq(CouponTable.email, email), isNull(CouponTable.timeRedeemed)))
.then((rows) => rows.map((row) => row.type)),
.insert(CouponTable)
.values({ email, type, timeRedeemed: sql`now()` })
.onDuplicateKeyUpdate({
set: {
timeRedeemed: sql`now()`,
},
}),
)
}
@@ -290,20 +289,29 @@ export namespace Billing {
if (billing.subscriptionID) throw new Error("Already subscribed to Black")
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
const coupons = await Billing.getCoupons(email)
const coupon = coupons.includes("GO12MONTHS100")
? LiteData.twelveMonths100Coupon
: coupons.includes("GO6MONTHS100")
? LiteData.sixMonths100Coupon
: coupons.includes("GO3MONTHS100")
? LiteData.threeMonths100Coupon
: coupons.includes("GOFREEMONTH")
? LiteData.firstMonth100Coupon
: LiteData.firstMonth50Coupon
const coupons = await Database.use((tx) =>
tx
.select({ type: CouponTable.type, timeRedeemed: CouponTable.timeRedeemed })
.from(CouponTable)
.where(eq(CouponTable.email, email)),
)
const coupon = (() => {
if (coupons.some((coupon) => coupon.type === "GO12MONTHS100" && !coupon.timeRedeemed))
return LiteData.twelveMonths100Coupon
if (coupons.some((coupon) => coupon.type === "GO6MONTHS100" && !coupon.timeRedeemed))
return LiteData.sixMonths100Coupon
if (coupons.some((coupon) => coupon.type === "GO3MONTHS100" && !coupon.timeRedeemed))
return LiteData.threeMonths100Coupon
if (coupons.some((coupon) => coupon.type === "GOFREEMONTH" && !coupon.timeRedeemed))
return LiteData.firstMonth100Coupon
if (!coupons.some((coupon) => coupon.type === "GO1MONTH50")) return LiteData.firstMonth50Coupon
return undefined
})()
const createSession = () =>
Billing.stripe().checkout.sessions.create({
mode: "subscription",
discounts: [{ coupon }],
discounts: coupon ? [{ coupon }] : undefined,
...(billing.customerID
? {
customer: billing.customerID,
@@ -133,7 +133,14 @@ export const UsageTable = mysqlTable(
(table) => [...workspaceIndexes(table), index("usage_time_created").on(table.workspaceID, table.timeCreated)],
)
export const CouponType = ["BUILDATHON", "GOFREEMONTH", "GO3MONTHS100", "GO6MONTHS100", "GO12MONTHS100"] as const
export const CouponType = [
"BUILDATHON",
"GO1MONTH50",
"GOFREEMONTH",
"GO3MONTHS100",
"GO6MONTHS100",
"GO12MONTHS100",
] as const
export const CouponTable = mysqlTable(
"coupon",
{
@@ -9,6 +9,8 @@ export namespace Subscription {
free: z.object({
promoTokens: z.number().int(),
dailyRequests: z.number().int(),
dailyRequestsFallback: z.number().int(),
checkHeaders: z.record(z.string(), z.string()),
}),
lite: z.object({
rollingLimit: z.number().int(),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.15.0",
"version": "1.15.4",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.15.0",
"version": "1.15.4",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.0",
"version": "1.15.4",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
-25
View File
@@ -5,15 +5,7 @@ function truthy(key: string) {
return value === "true" || value === "1"
}
function number(key: string) {
const value = process.env[key]
if (!value) return undefined
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
}
const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
const OPENCODE_DISABLE_CLAUDE_CODE = truthy("OPENCODE_DISABLE_CLAUDE_CODE")
const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
export const Flag = {
@@ -30,21 +22,14 @@ export const Flag = {
OPENCODE_DISABLE_TERMINAL_TITLE: truthy("OPENCODE_DISABLE_TERMINAL_TITLE"),
OPENCODE_SHOW_TTFD: truthy("OPENCODE_SHOW_TTFD"),
OPENCODE_PERMISSION: process.env["OPENCODE_PERMISSION"],
OPENCODE_DISABLE_DEFAULT_PLUGINS: truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS"),
OPENCODE_DISABLE_LSP_DOWNLOAD: truthy("OPENCODE_DISABLE_LSP_DOWNLOAD"),
OPENCODE_DISABLE_AUTOCOMPACT: truthy("OPENCODE_DISABLE_AUTOCOMPACT"),
OPENCODE_DISABLE_MODELS_FETCH: truthy("OPENCODE_DISABLE_MODELS_FETCH"),
OPENCODE_DISABLE_MOUSE: truthy("OPENCODE_DISABLE_MOUSE"),
OPENCODE_DISABLE_CLAUDE_CODE,
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
OPENCODE_DISABLE_EXTERNAL_SKILLS: truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"],
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
OPENCODE_ENABLE_QUESTION_TOOL: truthy("OPENCODE_ENABLE_QUESTION_TOOL"),
// Experimental
OPENCODE_EXPERIMENTAL,
OPENCODE_EXPERIMENTAL_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_FILEWATCHER").pipe(
Config.withDefault(false),
),
@@ -53,22 +38,12 @@ export const Flag = {
),
OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT:
copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"),
OPENCODE_ENABLE_EXA: truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA"),
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: number("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
OPENCODE_EXPERIMENTAL_LSP_TOOL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
OPENCODE_EXPERIMENTAL_PLAN_MODE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
OPENCODE_EXPERIMENTAL_SCOUT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SCOUT"),
OPENCODE_ENABLE_PARALLEL: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"),
OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"],
OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"],
OPENCODE_DB: process.env["OPENCODE_DB"],
OPENCODE_SKIP_MIGRATIONS: truthy("OPENCODE_SKIP_MIGRATIONS"),
OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"),
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"),
OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
OPENCODE_EXPERIMENTAL_SESSION_SWITCHING: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SESSION_SWITCHING"),
// Evaluated at access time (not module load) because tests, the CLI, and
// external tooling set these env vars at runtime.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.15.0",
"version": "1.15.4",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.15.0",
"version": "1.15.4",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.15.0"
version = "1.15.4"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.15.0",
"version": "1.15.4",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.0",
"version": "1.15.4",
"name": "@opencode-ai/http-recorder",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.0",
"version": "1.15.4",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
+3 -12
View File
@@ -128,17 +128,8 @@ See `specs/effect/migration.md` for the compact pattern reference and examples.
Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation rather than storing `Fiber | undefined` or `Promise | undefined` manually. See `specs/effect/migration.md` for the full pattern.
## Instance.bind — ALS for native callbacks
## Callback boundaries
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called.
Use `EffectBridge` for native or external callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, plugin callbacks, etc.) that need to re-enter Effect services with instance/workspace context.
Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish` or anything that reads `Instance.directory`.
You do not need it for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers.
```typescript
const cb = Instance.bind((err, evts) => {
Bus.publish(MyEvent, { ... })
})
nativeAddon.subscribe(dir, cb)
```
Plain async code should pass explicit context or stay inside an Effect fiber; do not add ambient instance context shims.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.0",
"version": "1.15.4",
"name": "opencode",
"type": "module",
"license": "MIT",
+1
View File
@@ -244,6 +244,7 @@ for (const item of targets) {
{
name,
version: Script.version,
preferUnplugged: true,
os: [item.os],
cpu: [item.arch],
},
+167 -80
View File
@@ -1,102 +1,189 @@
#!/usr/bin/env node
import childProcess from "child_process"
import fs from "fs"
import path from "path"
import os from "os"
import { fileURLToPath } from "url"
import path from "path"
import { createRequire } from "module"
import { fileURLToPath } from "url"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const require = createRequire(import.meta.url)
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8"))
function detectPlatformAndArch() {
// Map platform names
let platform
switch (os.platform()) {
case "darwin":
platform = "darwin"
break
case "linux":
platform = "linux"
break
case "win32":
platform = "windows"
break
default:
platform = os.platform()
break
}
// Map architecture names
let arch
switch (os.arch()) {
case "x64":
arch = "x64"
break
case "arm64":
arch = "arm64"
break
case "arm":
arch = "arm"
break
default:
arch = os.arch()
break
}
return { platform, arch }
const platformMap = {
darwin: "darwin",
linux: "linux",
win32: "windows",
}
const archMap = {
x64: "x64",
arm64: "arm64",
arm: "arm",
}
function findBinary() {
const { platform, arch } = detectPlatformAndArch()
const packageName = `opencode-${platform}-${arch}`
const binaryName = platform === "windows" ? "opencode.exe" : "opencode"
const platform = platformMap[os.platform()] ?? os.platform()
const arch = archMap[os.arch()] ?? os.arch()
const base = `opencode-${platform}-${arch}`
const sourceBinary = platform === "windows" ? "opencode.exe" : "opencode"
const targetBinary = path.join(__dirname, "bin", "opencode.exe")
try {
// Use require.resolve to find the package
const packageJsonPath = require.resolve(`${packageName}/package.json`)
const packageDir = path.dirname(packageJsonPath)
const binaryPath = path.join(packageDir, "bin", binaryName)
function supportsAvx2() {
if (arch !== "x64") return false
if (!fs.existsSync(binaryPath)) {
throw new Error(`Binary not found at ${binaryPath}`)
}
return { binaryPath, binaryName }
} catch (error) {
throw new Error(`Could not find package ${packageName}: ${error.message}`, { cause: error })
}
}
async function main() {
try {
if (os.platform() === "win32") {
// On Windows, the .exe is already included in the package and bin field points to it
// No postinstall setup needed
console.log("Windows detected: binary setup not needed (using packaged .exe)")
return
}
// On non-Windows platforms, just verify the binary package exists
// Don't replace the wrapper script - it handles binary execution
const { binaryPath } = findBinary()
const target = path.join(__dirname, "bin", ".opencode")
if (fs.existsSync(target)) fs.unlinkSync(target)
if (platform === "linux") {
try {
fs.linkSync(binaryPath, target)
return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8"))
} catch {
fs.copyFileSync(binaryPath, target)
return false
}
fs.chmodSync(target, 0o755)
} catch (error) {
console.error("Failed to setup opencode binary:", error.message)
process.exit(1)
}
if (platform === "darwin") {
try {
const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], {
encoding: "utf8",
timeout: 1500,
})
if (result.status !== 0) return false
return (result.stdout || "").trim() === "1"
} catch {
return false
}
}
if (platform === "windows") {
const command =
'(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)'
for (const executable of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) {
try {
const result = childProcess.spawnSync(executable, ["-NoProfile", "-NonInteractive", "-Command", command], {
encoding: "utf8",
timeout: 3000,
windowsHide: true,
})
if (result.status !== 0) continue
const output = (result.stdout || "").trim().toLowerCase()
if (output === "true" || output === "1") return true
if (output === "false" || output === "0") return false
} catch {
continue
}
}
}
return false
}
function isMusl() {
if (platform !== "linux") return false
try {
if (fs.existsSync("/etc/alpine-release")) return true
} catch {
// Ignore filesystem probes that are blocked by the host.
}
try {
const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" })
return `${result.stdout || ""}${result.stderr || ""}`.toLowerCase().includes("musl")
} catch {
return false
}
}
function packageNames() {
const baseline = arch === "x64" && !supportsAvx2()
if (platform === "linux") {
if (isMusl()) {
if (arch === "x64")
return baseline
? [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base]
: [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`]
return [`${base}-musl`, base]
}
if (arch === "x64")
return baseline
? [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`]
: [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`]
return [base, `${base}-musl`]
}
if (arch === "x64") return baseline ? [`${base}-baseline`, base] : [base, `${base}-baseline`]
return [base]
}
function resolveBinary(name) {
const packageJsonPath = require.resolve(`${name}/package.json`)
const binaryPath = path.join(path.dirname(packageJsonPath), "bin", sourceBinary)
if (!fs.existsSync(binaryPath)) throw new Error(`Binary not found at ${binaryPath}`)
return binaryPath
}
function installPackage(name) {
const version = packageJson.optionalDependencies?.[name]
if (!version) return
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-install-"))
try {
const result = childProcess.spawnSync(
"npm",
["install", "--ignore-scripts", "--no-save", "--loglevel=error", "--prefix", temp, `${name}@${version}`],
{ stdio: "inherit", windowsHide: true },
)
if (result.status !== 0) return
const packageDir = path.join(temp, "node_modules", name)
copyBinary(path.join(packageDir, "bin", sourceBinary), targetBinary)
return true
} finally {
fs.rmSync(temp, { recursive: true, force: true })
}
}
function copyBinary(source, target) {
if (!fs.existsSync(source)) throw new Error(`Binary not found at ${source}`)
fs.mkdirSync(path.dirname(target), { recursive: true })
if (fs.existsSync(target)) fs.unlinkSync(target)
try {
fs.linkSync(source, target)
} catch {
fs.copyFileSync(source, target)
}
fs.chmodSync(target, 0o755)
}
function verifyBinary() {
const result = childProcess.spawnSync(targetBinary, ["--version"], {
encoding: "utf8",
stdio: "ignore",
windowsHide: true,
})
return result.status === 0
}
function main() {
for (const name of packageNames()) {
try {
copyBinary(resolveBinary(name), targetBinary)
if (verifyBinary()) return
} catch {
if (installPackage(name) && verifyBinary()) return
}
}
throw new Error(
`It seems your package manager failed to install the right opencode CLI package. Try manually installing ${packageNames()
.map((name) => JSON.stringify(name))
.join(" or ")}.`,
)
}
try {
void main()
main()
} catch (error) {
console.error("Postinstall script error:", error.message)
process.exit(0)
console.error(error.message)
process.exit(1)
}
+20 -3
View File
@@ -32,22 +32,39 @@ console.log("binaries", binaries)
const version = Object.values(binaries)[0]
await $`mkdir -p ./dist/${pkg.name}`
await $`cp -r ./bin ./dist/${pkg.name}/bin`
await $`mkdir -p ./dist/${pkg.name}/bin`
await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs`
await Bun.file(`./dist/${pkg.name}/LICENSE`).write(await Bun.file("../../LICENSE").text())
await Bun.file(`./dist/${pkg.name}/bin/${pkg.name}.exe`).write(
[
`echo "Error: ${pkg.name}-ai's postinstall script was not run." >&2`,
'echo "" >&2',
'echo "This occurs when using --ignore-scripts during installation, or when using a" >&2',
'echo "package manager like pnpm that does not run postinstall scripts by default." >&2',
'echo "" >&2',
'echo "To fix this, run the postinstall script manually:" >&2',
`echo " cd node_modules/${pkg.name}-ai && node postinstall.mjs" >&2`,
'echo "" >&2',
`echo "Or reinstall ${pkg.name}-ai without the --ignore-scripts flag." >&2`,
"exit 1",
"",
].join("\n"),
)
await Bun.file(`./dist/${pkg.name}/package.json`).write(
JSON.stringify(
{
name: pkg.name + "-ai",
bin: {
[pkg.name]: `./bin/${pkg.name}`,
[pkg.name]: `./bin/${pkg.name}.exe`,
},
scripts: {
postinstall: "bun ./postinstall.mjs || node ./postinstall.mjs",
postinstall: "node ./postinstall.mjs",
},
version: version,
license: pkg.license,
os: ["darwin", "linux", "win32"],
cpu: ["arm64", "x64"],
optionalDependencies: binaries,
},
null,
+84
View File
@@ -70,11 +70,54 @@ Endpoint definitions declare which public errors can be emitted. Public
HTTP error schemas carry their response status with `httpApiStatus` or the
equivalent HttpApi schema annotation.
Effect's own HttpApi examples follow this pattern:
```ts
export class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
"Unauthorized",
{ message: Schema.String },
{ httpApiStatus: 401 },
) {}
export class Authorization extends HttpApiMiddleware.Service<
Authorization,
{
provides: CurrentUser
}
>()("app/Authorization", {
security: { bearer: HttpApiSecurity.bearer },
error: Unauthorized,
}) {}
```
Endpoint-level errors use the same idea:
```ts
export class ConfigApiError extends Schema.ErrorClass<ConfigApiError>("ConfigApiError")(
{
name: Schema.Union(Schema.Literal("ConfigInvalidError"), Schema.Literal("ConfigJsonError")),
data: Schema.Struct({ message: Schema.optional(Schema.String), path: Schema.String }),
},
{ httpApiStatus: 400 },
) {}
HttpApiEndpoint.get("get", "/config", {
success: Config.Info,
error: ConfigApiError,
})
```
The service error and HTTP error may be the same class only when the wire
shape is intentionally public. Use separate HTTP error schemas when the
service error contains internals, low-level causes, retry hints, or data
that should not be exposed to API clients.
Do not map every domain error into one universal HTTP error class. Prefer a
small public error vocabulary by route group: shared shapes like
`ApiNotFoundError`, route-specific shapes like `ConfigApiError`, and built-in
empty `HttpApiError.*` only when an empty/no-content body is the intended SDK
contract.
## Mapping Guidance
- Keep one-off translations inline in the handler.
@@ -86,6 +129,35 @@ that should not be exposed to API clients.
breaking API change.
- Use built-in `HttpApiError.*` only when its generated body and SDK
surface are intentionally the public contract.
- Prefer `Schema.ErrorClass` for public HTTP error bodies whose wire shape is
not the same as the internal domain error shape.
- Prefer `Schema.TaggedErrorClass` for service/domain errors and middleware
errors that are naturally tagged by `_tag`.
- If preserving a legacy `{ name, data }` body, model that shape explicitly in
the public API error schema instead of relying on `NamedError.toObject()` in
generic middleware.
## User-Facing Rendering
HTTP serialization and user rendering are separate boundaries. The server
should send structured public errors; CLI and TUI code should format those
structures through one shared formatter.
For SDK calls using `{ throwOnError: true }`, the generated client may wrap the
decoded response body in an `Error`. The original body should remain available
under `error.cause.body`; `FormatError` is the right place to unwrap and render
that body. TUI aggregation helpers should call `FormatError` first, then fall
back to generic `Error.message` / string rendering.
When several parallel startup requests fail from the same underlying issue,
group identical rendered messages and list the affected request names once.
For example:
```text
Configuration is invalid at /path/to/opencode.json
↳ Expected object, got "not-object" provider.bad.options
Affected startup requests: config.providers, provider.list, app.agents, config.get
```
## Middleware Guidance
@@ -99,6 +171,15 @@ middleware should shrink. It should not gain new name checks.
Unknown `500` responses should log full details server-side with
`Cause.pretty(cause)` and return a safe public body.
The config startup regression in #27056 is the failure mode this rule is meant
to avoid: a user-authored invalid `opencode.json` crossed the HttpApi boundary
as a defect, so middleware replaced a useful `ConfigInvalidError` with a safe
generic `UnknownError`. The compatibility fix is to preserve config parse and
validation errors as client-visible `400`s. The target architecture is better:
config loading should fail on the typed error channel, config HTTP handlers
should map those errors to declared `ConfigApiError` responses, and the generic
middleware should never see them.
## Migration Order
Prefer small vertical slices:
@@ -113,6 +194,9 @@ Prefer small vertical slices:
Good early domains are storage not-found, worktree errors, and provider
auth validation errors because they currently drive HTTP behavior.
Config parse and validation errors are also a good early slice because they
are startup-blocking and must be rendered clearly in both CLI and TUI flows.
## Checklist For A PR
- [ ] Expected failures are typed errors, not defects.
@@ -6,7 +6,6 @@ Current status on this branch:
- `src/` has 5 `makeRuntime(...)` call sites total.
- 2 are intentionally excluded from this checklist: `src/bus/index.ts` and `src/effect/cross-spawn-spawner.ts`.
- 1 is tracked primarily by the instance-context migration rather than facade removal: `src/project/instance.ts`.
- That leaves 2 live runtime-backed service facades still worth tracking here: `src/npm/index.ts` and `src/cli/cmd/tui/config/tui.ts`.
Recent progress:
@@ -18,7 +17,6 @@ Recent progress:
- `src/cli/cmd/tui/config/tui.ts` still exports `makeRuntime(...)` plus async facade helpers for `get()` and `waitForDependencies()`.
- `src/npm/index.ts` still exports `makeRuntime(...)` plus async facade helpers for `install()`, `add()`, `outdated()`, and `which()`.
- `src/project/instance.ts` still uses a dedicated runtime for project boot, but that file is really part of the broader legacy instance-context transition tracked in `instance-context.md`.
## Completed Batches
@@ -192,7 +190,6 @@ Most of the original facade-removal backlog is already done. The practical remai
1. remove the `Npm` runtime-backed facade from `src/npm/index.ts`
2. remove the `TuiConfig` runtime-backed facade from `src/cli/cmd/tui/config/tui.ts`
3. keep `src/project/instance.ts` in the separate instance-context migration, not this checklist
## Checklist
+3 -7
View File
@@ -197,13 +197,9 @@ For background loops, use `Effect.repeat` or `Effect.schedule` with
[`EffectBridge`](../../src/effect/bridge.ts) is the sanctioned helper for
Promise/callback interop that needs to preserve instance/workspace context.
Keep it, but reduce its dependency on legacy `Instance.current` /
`Instance.restore` over time.
`Instance.bind` / `Instance.restore` are transitional legacy tools. Use
them only for native callbacks that still require legacy ALS context. Do
not use them for `setTimeout`, `Promise.then`, `EventEmitter.on`, or
Effect fibers.
It preserves explicit `InstanceRef` / `WorkspaceRef` context for effects run
through the bridge. Plain JS callbacks that need instance data should receive
that data explicitly.
## Testing
@@ -1,309 +1,13 @@
# Instance context migration
# Instance Context
Practical plan for retiring the promise-backed / ALS-backed `Instance` helper in `src/project/instance.ts` and moving instance selection fully into Effect-provided scope.
Instance selection is now Effect-provided context.
## Goal
Use these APIs:
End state:
- `InstanceRef` for the current project context.
- `WorkspaceRef` for the current workspace id.
- `InstanceState.context` / `InstanceState.directory` inside Effect services that require an instance.
- `InstanceStore` at entry boundaries that need to load, reload, or dispose project contexts.
- `EffectBridge` for native, plugin, or plain JavaScript callback boundaries that need to re-enter Effect with captured refs.
- request, CLI, TUI, and tool entrypoints shift into an instance through Effect, not `Instance.provide(...)`
- Effect code reads the current instance from `InstanceRef` or its eventual replacement, not from ALS-backed sync getters
- per-directory boot, caching, and disposal are scoped Effect resources, not a module-level `Map<string, Promise<InstanceContext>>`
- ALS remains only as a temporary bridge for native callback APIs that fire outside the Effect fiber tree
## Current split
Today `src/project/instance.ts` still owns two separate concerns:
- ambient current-instance context through `LocalContext` / `AsyncLocalStorage`
- per-directory boot and deduplication through `cache: Map<string, Promise<InstanceContext>>`
At the same time, the Effect side already exists:
- `src/effect/instance-ref.ts` provides `InstanceRef` and `WorkspaceRef`
- `src/effect/run-service.ts` already attaches those refs when a runtime starts inside an active instance ALS context
- `src/effect/instance-state.ts` already prefers `InstanceRef` and only falls back to ALS when needed
That means the migration is not "invent instance context in Effect". The migration is "stop relying on the legacy helper as the primary source of truth".
## End state shape
Near-term target shape:
```ts
InstanceScope.with({ directory, workspaceID }, effect)
```
Responsibilities of `InstanceScope.with(...)`:
- resolve `directory`, `project`, and `worktree`
- acquire or reuse the scoped per-directory instance environment
- provide `InstanceRef` and `WorkspaceRef`
- run the caller's Effect inside that environment
Code inside the boundary should then do one of these:
```ts
const ctx = yield * InstanceState.context
const dir = yield * InstanceState.directory
```
Long-term, once `InstanceState` itself is replaced by keyed layers / `LayerMap`, those reads can move to an `InstanceContext` service without changing the outer migration order.
## Migration phases
### Phase 1: stop expanding the legacy surface
Rules for all new code:
- do not add new `Instance.directory`, `Instance.worktree`, `Instance.project`, or `Instance.current` reads inside Effect code
- do not add new `Instance.provide(...)` boundaries unless there is no Effect-native seam yet
- use `InstanceState.context`, `InstanceState.directory`, or an explicit `ctx` parameter inside Effect code
Success condition:
- the file inventory below only shrinks from here
### Phase 2: remove direct sync getter reads from Effect services
Convert Effect services first, before replacing the top-level boundary. These modules already run inside Effect and mostly need `yield* InstanceState.context` or a yielded `ctx` instead of ambient sync access.
Primary batch, highest payoff:
- `src/file/index.ts`
- `src/lsp/server.ts`
- `src/worktree/index.ts`
- `src/file/watcher.ts`
- `src/format/formatter.ts`
- `src/session/index.ts`
- `src/project/vcs.ts`
Mechanical replacement rule:
- `Instance.directory` -> `ctx.directory` or `yield* InstanceState.directory`
- `Instance.worktree` -> `ctx.worktree`
- `Instance.project` -> `ctx.project`
Do not thread strings manually through every public method if the service already has access to Effect context.
### Phase 3: convert entry boundaries to provide instance refs directly
After the service bodies stop assuming ALS, move the top-level boundaries to shift into Effect explicitly.
Main boundaries:
- HTTP server middleware and experimental `HttpApi` entrypoints
- CLI commands
- TUI worker / attach / thread entrypoints
- tool execution entrypoints
These boundaries should become Effect-native wrappers that:
- decode directory / workspace inputs
- resolve the instance context once
- provide `InstanceRef` and `WorkspaceRef`
- run the requested Effect
At that point `Instance.provide(...)` becomes a legacy adapter instead of the normal code path.
### Phase 4: replace promise boot cache with scoped instance runtime
Once boundaries and services both rely on Effect context, replace the module-level promise cache in `src/project/instance.ts`.
Target replacement:
- keyed scoped runtime or keyed layer acquisition for each directory
- reuse via `ScopedCache`, `LayerMap`, or another keyed Effect resource manager
- cleanup performed by scope finalizers instead of `disposeAll()` iterating a Promise map
This phase should absorb the current responsibilities of:
- `cache` in `src/project/instance.ts`
- `boot(...)`
- most of `disposeInstance(...)`
- manual `reload(...)` / `disposeAll()` fan-out logic
### Phase 5: shrink ALS to callback bridges only
Keep ALS only where a library invokes callbacks outside the Effect fiber tree and we still need to call code that reads instance context synchronously.
Known bridge cases today:
- `src/file/watcher.ts`
- `src/session/llm.ts`
- some LSP and plugin callback paths
If those libraries become fully wrapped in Effect services, the remaining `Instance.bind(...)` uses can disappear too.
### Phase 6: delete the legacy sync API
Only after earlier phases land:
- remove broad use of `Instance.current`, `Instance.directory`, `Instance.worktree`, `Instance.project`
- reduce `src/project/instance.ts` to a thin compatibility shim or delete it entirely
- remove the ALS fallback from `InstanceState.context`
## Inventory of direct legacy usage
Direct legacy usage means any source file that still calls one of:
- `Instance.current`
- `Instance.directory`
- `Instance.worktree`
- `Instance.project`
- `Instance.provide(...)`
- `Instance.bind(...)`
- `Instance.restore(...)`
- `Instance.reload(...)`
- `Instance.dispose()` / `Instance.disposeAll()`
Current total: `56` files in `packages/opencode/src`.
### Core bridge and plumbing
These files define or adapt the current bridge. They should change last, after callers have moved.
- `src/project/instance.ts`
- `src/effect/run-service.ts`
- `src/effect/instance-state.ts`
- `src/project/bootstrap.ts`
- `src/config/config.ts`
Migration rule:
- keep these as compatibility glue until the outer boundaries and inner services stop depending on ALS
### HTTP and server boundaries
These are the current request-entry seams that still create or consume instance context through the legacy helper.
- `src/server/routes/instance/middleware.ts`
- `src/server/routes/instance/index.ts`
- `src/server/routes/instance/project.ts`
- `src/server/routes/control/workspace.ts`
- `src/server/routes/instance/file.ts`
- `src/server/routes/instance/experimental.ts`
- `src/server/routes/global.ts`
Migration rule:
- move these to explicit Effect entrypoints that provide `InstanceRef` / `WorkspaceRef`
- do not move these first; first reduce the number of downstream handlers and services that still expect ambient ALS
### CLI and TUI boundaries
These commands still enter an instance through `Instance.provide(...)` or read sync getters directly.
- `src/cli/bootstrap.ts`
- `src/cli/cmd/agent.ts`
- `src/cli/cmd/debug/agent.ts`
- `src/cli/cmd/debug/ripgrep.ts`
- `src/cli/cmd/github.ts`
- `src/cli/cmd/import.ts`
- `src/cli/cmd/mcp.ts`
- `src/cli/cmd/models.ts`
- `src/cli/cmd/plug.ts`
- `src/cli/cmd/pr.ts`
- `src/cli/cmd/providers.ts`
- `src/cli/cmd/stats.ts`
- `src/cli/cmd/tui/attach.ts`
- `src/cli/cmd/tui/plugin/runtime.ts`
- `src/cli/cmd/tui/thread.ts`
- `src/cli/cmd/tui/worker.ts`
Migration rule:
- converge these on one shared `withInstance(...)` Effect entry helper instead of open-coded `Instance.provide(...)`
- after that helper is proven, inline the legacy implementation behind an Effect-native scope provider
### Tool boundary code
These tools mostly use direct getters for path resolution and repo-relative display logic.
- `src/tool/apply_patch.ts`
- `src/tool/bash.ts`
- `src/tool/edit.ts`
- `src/tool/lsp.ts`
- `src/tool/plan.ts`
- `src/tool/read.ts`
- `src/tool/write.ts`
Migration rule:
- expose the current instance as an explicit Effect dependency for tool execution
- keep path logic local; avoid introducing another global singleton for tool state
### Effect services still reading ambient instance state
These modules are already the best near-term migration targets because they are in Effect code but still read sync getters from the legacy helper.
- `src/agent/agent.ts`
- `src/cli/cmd/tui/config/tui-migrate.ts`
- `src/file/index.ts`
- `src/file/watcher.ts`
- `src/format/formatter.ts`
- `src/lsp/client.ts`
- `src/lsp/index.ts`
- `src/lsp/server.ts`
- `src/mcp/index.ts`
- `src/project/vcs.ts`
- `src/provider/provider.ts`
- `src/pty/index.ts`
- `src/session/session.ts`
- `src/session/instruction.ts`
- `src/session/llm.ts`
- `src/session/system.ts`
- `src/sync/index.ts`
- `src/worktree/index.ts`
Migration rule:
- replace direct getter reads with `yield* InstanceState.context` or a yielded `ctx`
- isolate `Instance.bind(...)` callers and convert only the truly callback-driven edges to bridge mode
### Highest-churn hotspots
Current highest direct-usage counts by file:
- `src/file/index.ts` - `18`
- `src/lsp/server.ts` - `14`
- `src/worktree/index.ts` - `12`
- `src/file/watcher.ts` - `9`
- `src/cli/cmd/mcp.ts` - `8`
- `src/format/formatter.ts` - `8`
- `src/tool/apply_patch.ts` - `8`
- `src/cli/cmd/github.ts` - `7`
These files should drive the first measurable burn-down.
## Recommended implementation order
1. Migrate direct getter reads inside Effect services, starting with `file`, `lsp`, `worktree`, `format`, and `session`.
2. Add one shared Effect-native boundary helper for CLI / tool / HTTP entrypoints so we stop open-coding `Instance.provide(...)`.
3. Move experimental `HttpApi` entrypoints to that helper so the new server stack proves the pattern.
4. Convert remaining CLI and tool boundaries.
5. Replace the promise cache with a keyed scoped runtime or keyed layer map.
6. Delete ALS fallback paths once only callback bridges still depend on them.
## Definition of done
This migration is done when all of the following are true:
- new requests and commands enter an instance by providing Effect context, not ALS
- Effect services no longer read `Instance.directory`, `Instance.worktree`, `Instance.project`, or `Instance.current`
- `Instance.provide(...)` is gone from normal request / CLI / tool execution
- per-directory boot and disposal are handled by scoped Effect resources
- `Instance.bind(...)` is either gone or confined to a tiny set of native callback adapters
## Tracker and worktree
Active tracker items:
- `lh7l73` - overall `HttpApi` migration
- `yobwlk` - remove direct `Instance.*` reads inside Effect services
- `7irl1e` - replace `InstanceState` / legacy instance caching with keyed Effect layers
Dedicated worktree for this transition:
- path: `/Users/kit/code/open-source/opencode-worktrees/instance-effect-shift`
- branch: `kit/instance-effect-shift`
Do not add new ambient instance globals. Promise and callback boundaries should either stay in Effect, use `EffectBridge`, or pass the required context explicitly.
@@ -24,10 +24,6 @@ Small follow-ups that do not fit neatly into the main facade, route, tool, or sc
- [ ] `cli/cmd/tui/config/tui.ts` - switch TUI config loading from async `ConfigPaths.*` wrappers to the `ConfigPaths.Service` once that service exists.
- [ ] `cli/cmd/tui/config/tui-migrate.ts` - decide whether to leave this as a plain async module using wrapper functions or effectify it fully after `ConfigPaths.Service` lands.
## Instance cleanup
- [ ] `project/instance.ts` - keep shrinking the legacy ALS / Promise cache after the remaining `Instance.*` callers move over.
## Notes
- Prefer small, semantics-preserving config migrations. Config precedence, legacy key migration, and plugin origin tracking are easy to break accidentally.
+8 -71
View File
@@ -64,13 +64,11 @@ P6 OA
explicit and testable instead of mutable module state.
Shrinks: [`global.ts`](../../../core/src/global.ts) import-time side
effects, mutable `Global.Path` overrides, and its `Flag` dependency.
- `INST` Instance shim — remove ambient `Instance` usage and old ALS
access patterns.
Shrinks: [`src/project/instance.ts`](../../src/project/instance.ts).
- `INST` Instance context — keep project context explicit through Effect refs
and bridge boundaries.
- `BRIDGE` Promise/callback interop — keep bridge helpers, but reduce
legacy ALS coupling.
Shrinks: [`src/effect/bridge.ts`](../../src/effect/bridge.ts)
dependency on [`project/instance.ts`](../../src/project/instance.ts).
Shrinks: ad hoc Promise/callback re-entry code.
- `PROC` AppProcess migration — prefer `AppProcess.Service` over raw
process wrappers.
Shrinks: direct spawn callsites and legacy process helpers.
@@ -221,74 +219,13 @@ Next PR candidates:
## P4: Instance And Bridge
[`project/instance.ts`](../../src/project/instance.ts) is the deletion
target. [`effect/bridge.ts`](../../src/effect/bridge.ts) is not a near-term
deletion target; Promise/callback interop will continue to exist.
Instance context migration is complete for the legacy sync shim. Promise and callback interop continues through [`effect/bridge.ts`](../../src/effect/bridge.ts).
Goal:
Current rules:
- Keep a sanctioned bridge for Promise/callback boundaries.
- Reduce bridge dependence on legacy `Instance.restore` / `Instance.current`.
- Move callers toward `InstanceRef`, `WorkspaceRef`, `InstanceState`, or
explicit context where practical.
- Delete `project/instance.ts` only after ambient Instance coupling is gone.
Important distinction:
- `InstanceState.context`, `InstanceState.directory`, and
`InstanceState.workspaceID` are acceptable inside normal Effect service
code when `InstanceRef` / `WorkspaceRef` are provided by the runtime.
- The deletion blockers are the fallback and callback paths that rely on
ambient ALS: direct `Instance.*` reads, `InstanceState.bind(...)`,
`AppRuntime.runPromise(...)` re-entry from plain JS, and bridge restore
code that installs legacy ALS before invoking callbacks.
Current bottom-up inventory from `dev`:
- Direct `Instance.*` value readers:
[`tool/repo_overview.ts`](../../src/tool/repo_overview.ts),
[`control-plane/adapters/worktree.ts`](../../src/control-plane/adapters/worktree.ts),
[`cli/bootstrap.ts`](../../src/cli/bootstrap.ts).
- `InstanceState.bind(...)` callback boundaries:
[`file/watcher.ts`](../../src/file/watcher.ts) native watcher callback,
[`storage/db.ts`](../../src/storage/db.ts) transaction/effect callbacks,
[`session/llm.ts`](../../src/session/llm.ts) workflow approval callback.
- `AppRuntime.runPromise(...)` / re-entry from plain JS:
[`project/with-instance.ts`](../../src/project/with-instance.ts),
[`project/instance-runtime.ts`](../../src/project/instance-runtime.ts),
[`control-plane/adapters/worktree.ts`](../../src/control-plane/adapters/worktree.ts),
[`cli/effect-cmd.ts`](../../src/cli/effect-cmd.ts), plus global/non-instance
callsites such as CLI upgrade and ACP agent defaults.
- Intentional bridge users to classify, not delete blindly:
workspace adapters in [`control-plane/workspace.ts`](../../src/control-plane/workspace.ts),
MCP, command execution, plugins, pty lifecycle, bus scope cleanup, task
cancellation, and HTTP lifecycle reload/dispose paths.
- Core fallback layer to shrink last:
[`effect/run-service.ts`](../../src/effect/run-service.ts),
[`effect/bridge.ts`](../../src/effect/bridge.ts), and
[`effect/instance-state.ts`](../../src/effect/instance-state.ts).
Recommended PR order:
- [ ] `INST-1` Remove direct `Instance.*` value readers. Start with
`repo_overview`, `worktree` adapter, and `cli/bootstrap`; pass context
explicitly or obtain it from an Effect boundary.
- [ ] `INST-2` Move type-only `InstanceContext` imports from
[`project/instance.ts`](../../src/project/instance.ts) to
[`project/instance-context.ts`](../../src/project/instance-context.ts).
- [ ] `INST-3` Audit each `InstanceState.bind(...)` callback from the inside
out: list what the callback calls (`Bus.publish`, database effects,
permission/session services), then replace ambient capture with explicit
`InstanceRef` / `WorkspaceRef` provision or an `EffectBridge` call.
- [ ] `INST-4` Classify `AppRuntime.runPromise(...)` callsites as global,
instance-scoped with explicit refs, or bridge-required. Eliminate the
instance-scoped callsites that rely on `run-service.attach()` falling
back to `Instance.current`.
- [ ] `INST-5` After consumers are explicit, remove `Instance.current` fallback
from `InstanceState.context` and `run-service.attach()`.
- [ ] `INST-6` Move any remaining `restore` / `bind` compatibility helpers to
the boundary that still needs them, then delete
[`project/instance.ts`](../../src/project/instance.ts).
- Effect services read instance data from `InstanceRef`, `WorkspaceRef`, `InstanceState`, or explicit arguments.
- Plain JavaScript callback boundaries use `EffectBridge` or explicit context arguments.
- Runtime entrypoints must provide refs explicitly when they are instance-scoped.
## Lower Priority Tracks
+3 -5
View File
@@ -39,10 +39,9 @@ import { Filesystem } from "@/util/filesystem"
import { Hash } from "@opencode-ai/core/util/hash"
import { ACPSessionManager } from "./session"
import type { ACPConfig } from "./types"
import { ACPRuntime } from "./runtime"
import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "../provider/schema"
import { Agent as AgentModule } from "../agent/agent"
import { AppRuntime } from "@/effect/app-runtime"
import { Installation } from "@/installation"
import { MessageV2 } from "@/session/message-v2"
import { Config } from "@/config/config"
@@ -1094,7 +1093,7 @@ export class Agent implements ACPAgent {
const currentModeId = await (async () => {
if (!availableModes.length) return undefined
const defaultAgent = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo()))
const defaultAgent = await ACPRuntime.defaultAgentInfo(directory)
const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgent.name)?.id ?? availableModes[0].id
this.sessionManager.setMode(sessionId, resolvedModeId)
return resolvedModeId
@@ -1328,8 +1327,7 @@ export class Agent implements ACPAgent {
if (!current) {
this.sessionManager.setModel(session.id, model)
}
const agent =
session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo()))).name
const agent = session.modeId ?? (await ACPRuntime.defaultAgentInfo(directory)).name
const parts: Array<
| { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
+22
View File
@@ -0,0 +1,22 @@
import { Agent } from "@/agent/agent"
import { AppRuntime, type AppServices } from "@/effect/app-runtime"
import { InstanceRef } from "@/effect/instance-ref"
import { InstanceRuntime } from "@/project/instance-runtime"
import { Effect } from "effect"
// Global ACP Effect re-entry: no project InstanceRef is provided.
export const runGlobal = AppRuntime.runPromise
// Directory-scoped ACP Effect re-entry: load the project instance and provide InstanceRef.
export async function runDirectory<A, E>(input: { directory: string; effect: Effect.Effect<A, E, AppServices> }) {
const ctx = await InstanceRuntime.load({ directory: input.directory })
return AppRuntime.runPromise(input.effect.pipe(Effect.provideService(InstanceRef, ctx)))
}
export const defaultAgentInfo = (directory: string) =>
runDirectory({
directory,
effect: Agent.Service.use((svc) => svc.defaultInfo()),
})
export * as ACPRuntime from "./runtime"
+4 -1
View File
@@ -6,6 +6,8 @@ import { GlobalBus } from "./global"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Identifier } from "@/id/id"
import type { InstanceContext } from "@/project/instance-context"
import { InstanceRef } from "@/effect/instance-ref"
const log = Log.create({ service: "bus" })
@@ -185,11 +187,12 @@ export function createID() {
}
export async function publish<D extends BusEvent.Definition>(
ctx: InstanceContext,
def: D,
properties: BusProperties<D>,
options?: { id?: string },
) {
return runPromise((svc) => svc.publish(def, properties, options))
return runPromise((svc) => svc.publish(def, properties, options).pipe(Effect.provideService(InstanceRef, ctx)))
}
export function subscribe<D extends BusEvent.Definition>(def: D, callback: (event: Payload<D>) => unknown) {
+7 -13
View File
@@ -1,17 +1,11 @@
import { Instance } from "../project/instance"
import { InstanceRuntime } from "../project/instance-runtime"
import { WithInstance } from "../project/with-instance"
import { context } from "../project/instance-context"
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
return WithInstance.provide({
directory,
fn: async () => {
try {
const result = await cb()
return result
} finally {
await InstanceRuntime.disposeInstance(Instance.current)
}
},
})
const ctx = await InstanceRuntime.load({ directory })
try {
return await context.provide(ctx, cb)
} finally {
await InstanceRuntime.disposeInstance(ctx)
}
}
+3 -1
View File
@@ -66,6 +66,8 @@ const AgentCreateCommand = effectCmd({
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
const ctx = maybeCtx
const agentSvc = yield* Agent.Service
const runLocalEffect = <A, E>(effect: Effect.Effect<A, E>) =>
Effect.runPromise(effect.pipe(Effect.provideService(InstanceRef, ctx)))
yield* Effect.promise(async () => {
const cliPath = args.path
const cliDescription = args.description
@@ -127,7 +129,7 @@ const AgentCreateCommand = effectCmd({
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) => {
const generated = await runLocalEffect(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()
+1 -1
View File
@@ -11,7 +11,7 @@ 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 type { InstanceContext } from "@/project/instance-context"
export const AgentCommand = effectCmd({
command: "agent <name>",
+35 -30
View File
@@ -435,6 +435,9 @@ export const GithubRunCommand = effectCmd({
const sessionSvc = yield* Session.Service
const sessionShare = yield* SessionShare.Service
const sessionPrompt = yield* SessionPrompt.Service
const busSvc = yield* Bus.Service
const runLocalEffect = <A, E>(effect: Effect.Effect<A, E>) =>
Effect.runPromise(effect.pipe(Effect.provideService(InstanceRef, ctx)))
yield* Effect.promise(async () => {
const isMock = args.token || args.event
@@ -548,7 +551,7 @@ export const GithubRunCommand = effectCmd({
// Setup opencode session
const repoData = await fetchRepo()
session = await Effect.runPromise(
session = await runLocalEffect(
sessionSvc.create({
permission: [
{
@@ -559,11 +562,11 @@ export const GithubRunCommand = effectCmd({
],
}),
)
subscribeSessionEvents()
await subscribeSessionEvents()
shareId = await (async () => {
if (share === false) return
if (!share && repoData.data.private) return
await Effect.runPromise(sessionShare.share(session.id))
await runLocalEffect(sessionShare.share(session.id))
return session.id.slice(-8)
})()
console.log("opencode session", session.id)
@@ -870,7 +873,7 @@ export const GithubRunCommand = effectCmd({
return { userPrompt: prompt, promptFiles: imgData }
}
function subscribeSessionEvents() {
async function subscribeSessionEvents() {
const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
bash: ["Shell", UI.Style.TEXT_DANGER_BOLD],
@@ -893,33 +896,35 @@ export const GithubRunCommand = effectCmd({
}
let text = ""
Bus.subscribe(MessageV2.Event.PartUpdated, (evt) => {
if (evt.properties.part.sessionID !== session.id) return
//if (evt.properties.part.messageID === messageID) return
const part = evt.properties.part
await runLocalEffect(
busSvc.subscribeCallback(MessageV2.Event.PartUpdated, (evt) => {
if (evt.properties.part.sessionID !== session.id) return
//if (evt.properties.part.messageID === messageID) return
const part = evt.properties.part
if (part.type === "tool" && part.state.status === "completed") {
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
const title =
part.state.title || Object.keys(part.state.input).length > 0
? JSON.stringify(part.state.input)
: "Unknown"
console.log()
printEvent(color, tool, title)
}
if (part.type === "text") {
text = part.text
if (part.time?.end) {
UI.empty()
UI.println(UI.markdown(text))
UI.empty()
text = ""
return
if (part.type === "tool" && part.state.status === "completed") {
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
const title =
part.state.title || Object.keys(part.state.input).length > 0
? JSON.stringify(part.state.input)
: "Unknown"
console.log()
printEvent(color, tool, title)
}
}
})
if (part.type === "text") {
text = part.text
if (part.time?.end) {
UI.empty()
UI.println(UI.markdown(text))
UI.empty()
text = ""
return
}
}
}),
)
}
async function summarize(response: string) {
@@ -936,7 +941,7 @@ export const GithubRunCommand = effectCmd({
async function chat(message: string, files: PromptFiles = []) {
console.log("Sending message to opencode...")
return Effect.runPromise(
return runLocalEffect(
Effect.gen(function* () {
const prompt = sessionPrompt
const result = yield* prompt.prompt({
@@ -1,11 +1,20 @@
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
function promptOffsetWidth(value: string) {
let width = 0
for (const part of graphemes.segment(value)) {
// Textarea offsets count newlines as one position; Bun.stringWidth counts them as zero.
width += part.segment === "\n" ? 1 : Bun.stringWidth(part.segment)
}
return width
}
function displayOffsetIndex(value: string, offset: number) {
if (offset <= 0) return 0
let width = 0
for (const part of graphemes.segment(value)) {
const next = width + Bun.stringWidth(part.segment)
const next = width + promptOffsetWidth(part.segment)
if (next > offset) return part.index
width = next
}
@@ -13,20 +22,20 @@ function displayOffsetIndex(value: string, offset: number) {
return value.length
}
export function displaySlice(value: string, start = 0, end = Bun.stringWidth(value)) {
export function displaySlice(value: string, start = 0, end = promptOffsetWidth(value)) {
return value.slice(displayOffsetIndex(value, start), displayOffsetIndex(value, end))
}
export function displayCharAt(value: string, offset: number) {
let width = 0
for (const part of graphemes.segment(value)) {
const next = width + Bun.stringWidth(part.segment)
const next = width + promptOffsetWidth(part.segment)
if (offset === width || offset < next) return part.segment
width = next
}
}
export function mentionTriggerIndex(value: string, offset = Bun.stringWidth(value)) {
export function mentionTriggerIndex(value: string, offset = promptOffsetWidth(value)) {
const text = displaySlice(value, 0, offset)
const index = text.lastIndexOf("@")
if (index === -1) return
@@ -34,6 +43,6 @@ export function mentionTriggerIndex(value: string, offset = Bun.stringWidth(valu
const before = index === 0 ? undefined : text[index - 1]
const query = text.slice(index)
if ((before === undefined || /\s/.test(before)) && !/\s/.test(query)) {
return Bun.stringWidth(text.slice(0, index))
return promptOffsetWidth(text.slice(0, index))
}
}
+5 -1
View File
@@ -24,6 +24,7 @@ import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@openc
import { Agent } from "@/agent/agent"
import { Permission } from "@/permission"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { InstanceRef } from "@/effect/instance-ref"
import { FormatError, FormatUnknownError } from "../error"
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin"
@@ -236,6 +237,7 @@ export const RunCommand = effectCmd({
handler: Effect.fn("Cli.run")(function* (args) {
const agentSvc = yield* Agent.Service
const flags = yield* RuntimeFlags.Service
const localInstance = yield* InstanceRef
yield* Effect.promise(async () => {
const rawMessage = [...args.message, ...(args["--"] || [])].join(" ")
const thinking = args.interactive ? (args.thinking ?? true) : (args.thinking ?? false)
@@ -508,7 +510,9 @@ export const RunCommand = effectCmd({
if (!args.agent) return undefined
const name = args.agent
const entry = await Effect.runPromise(agentSvc.get(name))
const entry = await Effect.runPromise(
agentSvc.get(name).pipe(Effect.provideService(InstanceRef, localInstance)),
)
if (!entry) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
+11 -41
View File
@@ -76,8 +76,6 @@ const appBindingCommands = [
"command.palette.show",
"session.list",
"session.new",
"session.cycle_recent",
"session.cycle_recent_reverse",
"session.quick_switch.1",
"session.quick_switch.2",
"session.quick_switch.3",
@@ -481,37 +479,15 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
dialog.clear()
},
},
...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
? [
{
name: "session.cycle_recent",
title: "Cycle to previous recent session",
category: "Session",
hidden: true,
run: () => {
local.session.cycleRecent(1)
},
},
{
name: "session.cycle_recent_reverse",
title: "Cycle to next recent session",
category: "Session",
hidden: true,
run: () => {
local.session.cycleRecent(-1)
},
},
...Array.from({ length: 9 }, (_, i) => ({
name: `session.quick_switch.${i + 1}`,
title: `Switch to session in quick slot ${i + 1}`,
category: "Session",
hidden: true,
run: () => {
local.session.quickSwitch(i + 1)
},
})),
]
: []),
...Array.from({ length: 9 }, (_, i) => ({
name: `session.quick_switch.${i + 1}`,
title: `Switch to session in quick slot ${i + 1}`,
category: "Session",
hidden: true,
run: () => {
local.session.quickSwitch(i + 1)
},
})),
{
name: "model.list",
title: "Switch model",
@@ -826,14 +802,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
useBindings(() => ({
enabled: command.matcher,
bindings: tuiConfig.keybinds.gather(
"app",
Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
? appBindingCommands
: appBindingCommands.filter(
(c) => !c.startsWith("session.cycle_recent") && !c.startsWith("session.quick_switch"),
),
),
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
}))
useBindings(() => ({
@@ -890,6 +859,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
})
event.on("installation.update-available", async (evt) => {
console.log("installation.update-available", evt)
const version = evt.properties.version
const skipped = kv.get("skipped_version")
@@ -31,6 +31,8 @@ export function DialogSessionList() {
const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150)
const deleteHint = useCommandShortcut("session.delete")
const quickSwitch1 = useCommandShortcut("session.quick_switch.1")
const quickSwitch9 = useCommandShortcut("session.quick_switch.9")
const [searchResults, { refetch }] = createResource(
() => ({ query: search(), filter: sync.session.query() }),
@@ -130,10 +132,18 @@ export function DialogSessionList() {
const [browseOrder] = createSignal<string[]>(orderByRecency(sync.data.session))
const RECENT_LIMIT = 5
const quickSwitchHint = createMemo(() => {
const first = quickSwitch1()
const last = quickSwitch9()
if (!first || !last) return undefined
return quickSwitchRange(first, last)
})
const quickSwitchFooterHints = createMemo(() => {
const hint = quickSwitchHint()
return hint && local.session.slots().length > 0 ? [{ title: "switch", label: hint }] : []
})
const options = createMemo(() => {
const enabled = Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
const today = new Date().toDateString()
const sessionMap = new Map(
sessions()
@@ -144,17 +154,9 @@ export function DialogSessionList() {
const searchResult = searchResults()
const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder()
const dismissed = enabled ? new Set(local.session.dismissedRecent()) : new Set<string>()
const pinned = enabled ? local.session.pinned().filter((id) => sessionMap.has(id)) : []
const pinned = local.session.pinned().filter((id) => sessionMap.has(id))
const pinnedSet = new Set(pinned)
const slotByID = enabled
? new Map<string, number>(local.session.slots().map((id, i) => [id, i + 1]))
: new Map<string, number>()
const recent = enabled
? displayOrder.filter((id) => !pinnedSet.has(id) && !dismissed.has(id)).slice(0, RECENT_LIMIT)
: []
const recentSet = new Set(recent)
const slotByID = new Map<string, number>(local.session.slots().map((id, i) => [id, i + 1]))
function buildOption(id: string, category: string) {
const x = sessionMap.get(id)
@@ -198,7 +200,7 @@ export function DialogSessionList() {
}
const remaining = displayOrder
.filter((id) => !pinnedSet.has(id) && !recentSet.has(id))
.filter((id) => !pinnedSet.has(id))
.map((id) => {
const x = sessionMap.get(id)
if (!x) return undefined
@@ -207,11 +209,7 @@ export function DialogSessionList() {
})
.filter((x) => x !== undefined)
return [
...pinned.map((id) => buildOption(id, "Pinned")).filter((x) => x !== undefined),
...recent.map((id) => buildOption(id, "Recent")).filter((x) => x !== undefined),
...remaining,
]
return [...pinned.map((id) => buildOption(id, "Pinned")).filter((x) => x !== undefined), ...remaining]
})
onMount(() => {
@@ -236,32 +234,13 @@ export function DialogSessionList() {
dialog.clear()
}}
actions={[
...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
? [
{
command: "session.pin.toggle",
title: "pin/unpin",
onTrigger: (option: { value: string }) => {
local.session.togglePin(option.value)
},
},
{
command: "session.toggle.recent",
title: "toggle recent",
onTrigger: (option: { value: string }) => {
if (local.session.isPinned(option.value)) {
toast.show({
variant: "info",
message: "Unpin the session first to toggle it in Recent",
duration: 3000,
})
return
}
local.session.toggleRecent(option.value)
},
},
]
: []),
{
command: "session.pin.toggle",
title: "pin/unpin",
onTrigger: (option: { value: string }) => {
local.session.togglePin(option.value)
},
},
{
command: "session.delete",
title: "delete",
@@ -318,6 +297,13 @@ export function DialogSessionList() {
},
},
]}
footerHints={quickSwitchFooterHints()}
/>
)
}
function quickSwitchRange(first: string, last: string) {
const prefix = first.slice(0, -1)
if (first.endsWith("1") && last === `${prefix}9`) return `${prefix}1-9`
return `${first} through ${last}`
}
@@ -27,6 +27,11 @@ export type PromptInfo = {
const MAX_HISTORY_ENTRIES = 50
export function isDuplicateEntry(previous: PromptInfo | undefined, next: PromptInfo): boolean {
if (!previous) return false
return JSON.stringify(previous) === JSON.stringify(next)
}
export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({
name: "PromptHistory",
init: () => {
@@ -83,6 +88,10 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
},
append(item: PromptInfo) {
const entry = structuredClone(unwrap(item))
if (isDuplicateEntry(store.history.at(-1), entry)) {
setStore("index", 0)
return
}
let trimmed = false
setStore(
produce((draft) => {
@@ -87,9 +87,6 @@ export const Definitions = {
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
session_parent: keybind("up", "Go to parent session"),
session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"),
session_toggle_recent: keybind("ctrl+h", "Show or hide session in the Recent group"),
session_cycle_recent: keybind("<leader>]", "Cycle to the previous recent session"),
session_cycle_recent_reverse: keybind("<leader>[", "Cycle to the next recent session"),
session_quick_switch_1: keybind("<leader>1", "Switch to session in quick slot 1"),
session_quick_switch_2: keybind("<leader>2", "Switch to session in quick slot 2"),
session_quick_switch_3: keybind("<leader>3", "Switch to session in quick slot 3"),
@@ -273,9 +270,6 @@ export const CommandMap = {
session_child_cycle_reverse: "session.child.previous",
session_parent: "session.parent",
session_pin_toggle: "session.pin.toggle",
session_toggle_recent: "session.toggle.recent",
session_cycle_recent: "session.cycle_recent",
session_cycle_recent_reverse: "session.cycle_recent_reverse",
session_quick_switch_1: "session.quick_switch.1",
session_quick_switch_2: "session.quick_switch.2",
session_quick_switch_3: "session.quick_switch.3",
@@ -1,3 +1,5 @@
import { FormatError } from "@/cli/error"
/**
* Aggregate Promise.allSettled results into a single Error that names every
* failed endpoint, or return null when all fulfilled. Used at TUI bootstrap
@@ -15,7 +17,19 @@ export function aggregateFailures(labeled: LabeledSettled[]): Error | null {
)
if (failed.length === 0) return null
const reasons = failed.map((f) => `${f.name}: ${reasonMessage(f.result.reason)}`).join("; ")
const reasons = Array.from(
failed
.map((f) => ({ name: f.name, message: reasonMessage(f.result.reason) }))
.reduce((grouped, failure) => {
grouped.set(failure.message, [...(grouped.get(failure.message) ?? []), failure.name])
return grouped
}, new Map<string, string[]>())
.entries(),
)
.map(([message, names]) =>
names.length === 1 ? `${names[0]}: ${message}` : `${message}\nAffected startup requests: ${names.join(", ")}`,
)
.join("; ")
const summary = `${failed.length} of ${labeled.length} requests failed: ${reasons}`
const err = new Error(summary)
err.cause = { failures: failed.map((f) => ({ name: f.name, reason: f.result.reason })) }
@@ -23,6 +37,9 @@ export function aggregateFailures(labeled: LabeledSettled[]): Error | null {
}
function reasonMessage(reason: unknown): string {
const formatted = FormatError(reason)
if (formatted) return formatted
if (reason instanceof Error) return reason.message
if (typeof reason === "string") return reason
if (reason && typeof reason === "object") {
@@ -1,6 +1,6 @@
import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { batch, createEffect, createMemo, on } from "solid-js"
import { batch, createEffect, createMemo } from "solid-js"
import { useSync } from "@tui/context/sync"
import { useTheme } from "@tui/context/theme"
import { useRoute } from "@tui/context/route"
@@ -8,7 +8,6 @@ import { useEvent } from "@tui/context/event"
import { uniqueBy } from "remeda"
import path from "path"
import { Global } from "@opencode-ai/core/global"
import { Flag } from "@opencode-ai/core/flag/flag"
import { iife } from "@/util/iife"
import { useToast } from "../ui/toast"
import { useArgs } from "./args"
@@ -387,13 +386,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const [sessionStore, setSessionStore] = createStore<{
ready: boolean
pinned: string[]
dismissedRecent: string[]
recentOrder: string[]
}>({
ready: false,
pinned: [],
dismissedRecent: [],
recentOrder: [],
})
const filePath = path.join(Global.Path.state, "session.json")
@@ -409,16 +404,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
state.pending = false
void Filesystem.writeJson(filePath, {
pinned: sessionStore.pinned,
dismissedRecent: sessionStore.dismissedRecent,
recentOrder: sessionStore.recentOrder,
})
}
Filesystem.readJson(filePath)
.then((x: any) => {
if (Array.isArray(x.pinned)) setSessionStore("pinned", x.pinned)
if (Array.isArray(x.dismissedRecent)) setSessionStore("dismissedRecent", x.dismissedRecent)
if (Array.isArray(x.recentOrder)) setSessionStore("recentOrder", x.recentOrder)
})
.catch(() => {})
.finally(() => {
@@ -428,19 +419,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const route = useRoute()
const event = useEvent()
let cycling = false
const slots = createMemo(() => {
const rootSessions = sync.data.session.filter((x) => x.parentID === undefined)
const existing = new Set(rootSessions.map((x) => x.id))
const dismissed = new Set(sessionStore.dismissedRecent)
const pins = sessionStore.pinned.filter((id) => existing.has(id))
const pinnedSet = new Set(pins)
const recent = rootSessions
.filter((x) => !pinnedSet.has(x.id) && !dismissed.has(x.id))
.toSorted((a, b) => b.time.updated - a.time.updated)
.map((x) => x.id)
return [...pins, ...recent].slice(0, 9)
const existing = new Set(sync.data.session.filter((x) => x.parentID === undefined).map((x) => x.id))
return sessionStore.pinned.filter((id) => existing.has(id)).slice(0, 9)
})
function prune(sessionID: string) {
@@ -451,18 +433,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
sessionStore.pinned.filter((x) => x !== sessionID),
)
}
if (sessionStore.dismissedRecent.includes(sessionID)) {
setSessionStore(
"dismissedRecent",
sessionStore.dismissedRecent.filter((x) => x !== sessionID),
)
}
if (sessionStore.recentOrder.includes(sessionID)) {
setSessionStore(
"recentOrder",
sessionStore.recentOrder.filter((x) => x !== sessionID),
)
}
save()
})
}
@@ -471,25 +441,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
prune(evt.properties.info.id)
})
if (Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING) {
createEffect(
on(
() => (sessionStore.ready && route.data.type === "session" ? route.data.sessionID : undefined),
(sessionID) => {
if (!sessionID) return
if (cycling) {
cycling = false
return
}
const filtered = sessionStore.recentOrder.filter((x) => x !== sessionID)
const next = [sessionID, ...filtered].slice(0, 20)
setSessionStore("recentOrder", next)
save()
},
),
)
}
return {
get ready() {
return sessionStore.ready
@@ -497,75 +448,26 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
pinned() {
return sessionStore.pinned
},
dismissedRecent() {
return sessionStore.dismissedRecent
},
recentOrder() {
return sessionStore.recentOrder
},
slots,
isPinned(sessionID: string) {
return sessionStore.pinned.includes(sessionID)
},
isDismissed(sessionID: string) {
return sessionStore.dismissedRecent.includes(sessionID)
},
togglePin(sessionID: string) {
batch(() => {
const exists = sessionStore.pinned.includes(sessionID)
const next = exists
? sessionStore.pinned.filter((x) => x !== sessionID)
: [sessionID, ...sessionStore.pinned]
: [...sessionStore.pinned, sessionID]
setSessionStore("pinned", next)
save()
})
},
toggleRecent(sessionID: string) {
batch(() => {
const exists = sessionStore.dismissedRecent.includes(sessionID)
const next = exists
? sessionStore.dismissedRecent.filter((x) => x !== sessionID)
: [sessionID, ...sessionStore.dismissedRecent]
setSessionStore("dismissedRecent", next)
save()
})
},
quickSwitch(slot: number) {
const target = slots()[slot - 1]
if (!target) return
if (route.data.type === "session" && route.data.sessionID === target) return
route.navigate({ type: "session", sessionID: target })
},
cycleRecent(direction: 1 | -1) {
if (route.data.type !== "session") {
toast.show({
variant: "info",
message: "Open a session first to cycle between recent sessions",
duration: 3000,
})
return
}
const current = route.data.sessionID
const order = sessionStore.recentOrder.filter((id) =>
sync.data.session.some((s) => s.id === id && s.parentID === undefined),
)
if (order.length < 2) {
toast.show({
variant: "info",
message: "No other recent sessions to cycle to",
duration: 3000,
})
return
}
const index = order.indexOf(current)
if (index === -1) return
const next = index + direction
if (next < 0 || next >= order.length) return
const target = order[next]
if (!target || target === current) return
cycling = true
route.navigate({ type: "session", sessionID: target })
},
}
})
@@ -962,6 +962,7 @@ function getSyntaxRules(theme: Theme) {
style: {
foreground: theme.markdownHeading,
bold: true,
underline: true,
},
},
{
@@ -0,0 +1,66 @@
import { createMemo, type Setter } from "solid-js"
import { useKV } from "./kv"
export type ThinkingMode = "show" | "hide"
const MODES: readonly ThinkingMode[] = ["show", "hide"] as const
// OpenAI's Responses API surfaces reasoning summaries that start with a bolded
// title line: "**Inspecting PR workflow**\n\n<body>". GitHub Copilot routes
// through the same shape, and the opencode provider relays it too. Pull the
// title out for a nicer label; return null for providers that don't follow
// this convention so the caller can fall back to a generic "Thinking" string.
export function reasoningTitle(text: string): string | null {
const match = text.trimStart().match(/^\*\*([^*\n]+)\*\*/)
return match ? match[1].trim() : null
}
export function isThinkingMode(value: unknown): value is ThinkingMode {
return typeof value === "string" && (MODES as readonly string[]).includes(value)
}
// Cycle order matches the slash command: show → hide → show.
export function nextThinkingMode(current: ThinkingMode): ThinkingMode {
const idx = MODES.indexOf(current)
return MODES[(idx + 1) % MODES.length] ?? "show"
}
export function useThinkingMode() {
const kv = useKV()
// Capture pre-state before `kv.signal` seeds a default, so we can detect
// first-time users with a legacy `thinking_visibility` boolean and migrate.
// The KVProvider only renders children once kv.ready, so reads here are safe.
const hadStored = kv.get("thinking_mode") !== undefined
const legacy = kv.get("thinking_visibility")
const [stored, setStored] = kv.signal<ThinkingMode>("thinking_mode", "hide")
// The kv signal exposes its setter typed as `Setter<T>` which carries Solid's
// overload set; passing an updater fn through a property access loses the
// bivariance trick the existing `setX((prev) => ...)` callsites rely on.
// Wrap it in a sane shape so consumers can just call `set(next)` or pass
// an updater.
const set = (next: ThinkingMode | ((prev: ThinkingMode) => ThinkingMode)) => {
if (typeof next === "function") setStored(next as Setter<ThinkingMode>)
else setStored(() => next)
}
// Preserve previous experience for users who had explicitly toggled the
// legacy `thinking_visibility` boolean. First-time users (no legacy key)
// get the new "hide" default (collapsed thinking).
if (!hadStored) {
if (legacy === true) set("show")
else if (legacy === false) set("hide")
}
if ((stored() as string) === "minimal") set("hide")
const mode = createMemo<ThinkingMode>(() => {
const value = stored()
return isThinkingMode(value) ? value : "hide"
})
return {
mode,
set,
}
}
@@ -1,7 +1,6 @@
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
import { createMemo, For, type Accessor } from "solid-js"
import { DEFAULT_THEMES, useTheme } from "@tui/context/theme"
import { Flag } from "@opencode-ai/core/flag/flag"
import { useCommandShortcut } from "../../keymap"
const themeCount = Object.keys(DEFAULT_THEMES).length
@@ -29,8 +28,6 @@ type Shortcuts = {
messagesToggleConceal: TipShortcut
modelCycleRecent: TipShortcut
modelList: TipShortcut
sessionCycleRecent: TipShortcut
sessionCycleRecentReverse: TipShortcut
sessionExport: TipShortcut
sessionInterrupt: TipShortcut
sessionList: TipShortcut
@@ -41,7 +38,6 @@ type Shortcuts = {
sessionQuickSwitch9: TipShortcut
sessionSidebarToggle: TipShortcut
sessionTimeline: TipShortcut
sessionToggleRecent: TipShortcut
statusView: TipShortcut
terminalSuspend: TipShortcut
themeList: TipShortcut
@@ -73,6 +69,7 @@ function parse(tip: string): TipPart[] {
}
const NO_MODELS_TIP = "Run {highlight}/connect{/highlight} to add an AI provider and start coding"
const NO_MODELS_PARTS = parse(NO_MODELS_TIP)
function shortcutText(value: string) {
return `{highlight}${value}{/highlight}`
@@ -121,8 +118,6 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
messagesToggleConceal: configShortcut(props.api, "session.toggle.conceal"),
modelCycleRecent: useCommandShortcut("model.cycle_recent"),
modelList: useCommandShortcut("model.list"),
sessionCycleRecent: useCommandShortcut("session.cycle_recent"),
sessionCycleRecentReverse: useCommandShortcut("session.cycle_recent_reverse"),
sessionExport: configShortcut(props.api, "session.export"),
sessionInterrupt: configShortcut(props.api, "session.interrupt"),
sessionList: useCommandShortcut("session.list"),
@@ -133,7 +128,6 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
sessionQuickSwitch9: useCommandShortcut("session.quick_switch.9"),
sessionSidebarToggle: configShortcut(props.api, "session.sidebar.toggle"),
sessionTimeline: configShortcut(props.api, "session.timeline"),
sessionToggleRecent: configShortcut(props.api, "session.toggle.recent"),
statusView: useCommandShortcut("opencode.status"),
terminalSuspend: useCommandShortcut("terminal.suspend"),
themeList: useCommandShortcut("theme.switch"),
@@ -145,8 +139,13 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
return value ? [value] : []
})
return tips[Math.floor(tipOffset * tips.length)] ?? NO_MODELS_TIP
})
const parts = createMemo(() => parse(tip()))
}, NO_MODELS_TIP)
// Solid can expose a memo's initial value while a pure computation is pending.
const parts = createMemo(() => {
const value = tip()
if (typeof value === "string") return parse(value)
return NO_MODELS_PARTS
}, NO_MODELS_PARTS)
return (
<box flexDirection="row" maxWidth="100%">
@@ -176,23 +175,12 @@ const TIPS: Tip[] = [
(shortcuts) => `Use ${commandText("/models", shortcuts.modelList())} to see and switch between available AI models`,
(shortcuts) => `Use ${commandText("/themes", shortcuts.themeList())} to switch between ${themeCount} built-in themes`,
(shortcuts) => `Use ${commandText("/new", shortcuts.sessionNew())} to start a fresh conversation session`,
(shortcuts) => `Use ${commandText("/sessions", shortcuts.sessionList())} to list and continue previous conversations`,
...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
? ([
(shortcuts) =>
press(shortcuts.sessionPinToggle(), "in the session list to pin a session so it stays at the top"),
(shortcuts) =>
shortcuts.sessionQuickSwitch1() && shortcuts.sessionQuickSwitch9()
? `Pinned and recent sessions are bound to ${shortcutText(shortcuts.sessionQuickSwitch1())} through ${shortcutText(shortcuts.sessionQuickSwitch9())} for one-press switching`
: undefined,
(shortcuts) =>
shortcuts.sessionCycleRecent() && shortcuts.sessionCycleRecentReverse()
? `Press ${shortcutText(shortcuts.sessionCycleRecent())} / ${shortcutText(shortcuts.sessionCycleRecentReverse())} to cycle through recently visited sessions`
: undefined,
(shortcuts) =>
press(shortcuts.sessionToggleRecent(), "in the session list to show or hide a session in the Recent group"),
] satisfies Tip[])
: []),
(shortcuts) => `Use ${commandText("/sessions", shortcuts.sessionList())} to list, pin, and continue sessions`,
(shortcuts) => press(shortcuts.sessionPinToggle(), "in the session list to pin a session so it stays at the top"),
(shortcuts) =>
shortcuts.sessionQuickSwitch1() && shortcuts.sessionQuickSwitch9()
? `Pinned sessions are assigned quick slots; use ${shortcutText(shortcuts.sessionQuickSwitch1())} through ${shortcutText(shortcuts.sessionQuickSwitch9())} to switch`
: undefined,
"Run {highlight}/compact{/highlight} to summarize long sessions near context limits",
(shortcuts) => `Use ${commandText("/export", shortcuts.sessionExport())} to save the conversation as Markdown`,
(shortcuts) => press(shortcuts.messagesCopy(), "to copy the assistant's last message to clipboard"),
@@ -5,6 +5,7 @@ import { SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner"
import { useTheme } from "@tui/context/theme"
import { useLocal } from "@tui/context/local"
import { reasoningTitle, useThinkingMode } from "@tui/context/thinking"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
import { useBindings } from "../../keymap"
@@ -317,7 +318,11 @@ function AssistantMessage(props: {
<AssistantText part={part as SessionMessageAssistantText} syntax={props.syntax} />
</Match>
<Match when={part.type === "reasoning"}>
<AssistantReasoning part={part as SessionMessageAssistantReasoning} subtleSyntax={props.subtleSyntax} />
<AssistantReasoning
part={part as SessionMessageAssistantReasoning}
subtleSyntax={props.subtleSyntax}
completedAt={() => props.message.time.completed}
/>
</Match>
<Match when={part.type === "tool"}>
<AssistantTool part={part as SessionMessageAssistantTool} sessionID={props.sessionID} />
@@ -378,30 +383,64 @@ function AssistantText(props: { part: SessionMessageAssistantText; syntax: Synta
)
}
function AssistantReasoning(props: { part: SessionMessageAssistantReasoning; subtleSyntax: SyntaxStyle }) {
function AssistantReasoning(props: {
part: SessionMessageAssistantReasoning
subtleSyntax: SyntaxStyle
completedAt: () => number | undefined
}) {
const { theme } = useTheme()
const thinking = useThinkingMode()
const [expanded, setExpanded] = createSignal(false)
const content = createMemo(() => props.part.text.replace("[REDACTED]", "").trim())
const inMinimal = createMemo(() => thinking.mode() === "hide")
// v2 reasoning parts have no per-part `time.end` (see SessionMessageAssistantReasoning
// in the v2 SDK); we settle on parent-message completion instead.
const isDone = createMemo(() => props.completedAt() !== undefined)
const title = createMemo(() => reasoningTitle(content()))
const toggle = () => {
if (!inMinimal()) return
setExpanded((prev) => !prev)
}
return (
<Show when={content()}>
<box
paddingLeft={2}
marginTop={1}
flexDirection="column"
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.backgroundElement}
flexShrink={0}
>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={props.subtleSyntax}
content={"_Thinking:_ " + content()}
conceal={true}
fg={theme.textMuted}
/>
</box>
<Switch>
<Match when={!inMinimal() || expanded()}>
<box
paddingLeft={2}
marginTop={1}
flexDirection="column"
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.backgroundElement}
flexShrink={0}
onMouseUp={toggle}
>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={props.subtleSyntax}
content={(inMinimal() ? "▼ " : "") + "_Thinking:_ " + content()}
conceal={true}
fg={theme.textMuted}
/>
</box>
</Match>
<Match when={isDone()}>
<box paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
<text fg={theme.textMuted} wrapMode="none">
{title() ? "▶ Thought: " + title() : "▶ Thought"}
</text>
</box>
</Match>
<Match when={true}>
<box paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
<Spinner color={theme.textMuted}>{title() ? "Thinking: " + title() : "Thinking"}</Spinner>
</box>
</Match>
</Switch>
</Show>
)
}
@@ -82,6 +82,7 @@ import * as Model from "../../util/model"
import { formatTranscript } from "../../util/transcript"
import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config"
import { nextThinkingMode, reasoningTitle, useThinkingMode, type ThinkingMode } from "../../context/thinking"
import { getScrollAcceleration } from "../../util/scroll"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { DialogRetryAction } from "../../component/dialog-retry-action"
@@ -157,6 +158,7 @@ const context = createContext<{
width: number
sessionID: string
conceal: () => boolean
thinkingMode: () => ThinkingMode
showThinking: () => boolean
showTimestamps: () => boolean
showDetails: () => boolean
@@ -214,7 +216,9 @@ export function Session() {
const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto")
const [sidebarOpen, setSidebarOpen] = createSignal(false)
const [conceal, setConceal] = createSignal(true)
const [showThinking, setShowThinking] = kv.signal("thinking_visibility", true)
const thinking = useThinkingMode()
const thinkingMode = thinking.mode
const showThinking = createMemo(() => true)
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
const [showAssistantMetadata, _setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
@@ -683,7 +687,11 @@ export function Session() {
},
},
{
title: showThinking() ? "Hide thinking" : "Show thinking",
title: (() => {
const next = nextThinkingMode(thinkingMode())
if (next === "hide") return "Collapse thinking"
return "Expand thinking"
})(),
value: "session.toggle.thinking",
category: "Session",
slash: {
@@ -691,7 +699,7 @@ export function Session() {
aliases: ["toggle-thinking"],
},
run: () => {
setShowThinking((prev) => !prev)
thinking.set(nextThinkingMode(thinkingMode()))
dialog.clear()
},
},
@@ -1086,6 +1094,7 @@ export function Session() {
},
sessionID: route.sessionID,
conceal,
thinkingMode,
showThinking,
showTimestamps,
showDetails,
@@ -1492,32 +1501,77 @@ const PART_MAPPING = {
function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
const { theme, subtleSyntax } = useTheme()
const ctx = use()
// Collapsed by default in hide mode: a single line throughout, so the
// layout never shifts. Click to open the full markdown block, click to close.
const [expanded, setExpanded] = createSignal(false)
const content = createMemo(() => {
// Filter out redacted reasoning chunks from OpenRouter
// OpenRouter sends encrypted reasoning data that appears as [REDACTED]
// OpenRouter encrypts some reasoning blocks; drop the placeholder.
return props.part.text.replace("[REDACTED]", "").trim()
})
// Reasoning is finalized when the server sets `time.end` (see processor.ts).
// Flips independently of the parent message completing.
const isDone = createMemo(() => props.part.time.end !== undefined)
const inMinimal = createMemo(() => ctx.thinkingMode() === "hide")
const duration = createMemo(() => {
const end = props.part.time.end
return end === undefined ? 0 : Math.max(0, end - props.part.time.start)
})
// OpenAI / Copilot / opencode-via-OpenAI emit `**Title**\n\n<body>` summary
// blocks. Surface the title both while streaming and after settling so the
// collapsed line carries real signal, not just a duration.
const title = createMemo(() => reasoningTitle(content()))
const toggle = () => {
if (!inMinimal()) return
setExpanded((prev) => !prev)
}
return (
<Show when={content() && ctx.showThinking()}>
<box
id={"text-" + props.part.id}
paddingLeft={2}
marginTop={1}
flexDirection="column"
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.backgroundElement}
>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={subtleSyntax()}
content={"_Thinking:_ " + content()}
conceal={ctx.conceal()}
fg={theme.textMuted}
/>
</box>
<Show when={content()}>
<Switch>
<Match when={!inMinimal() || expanded()}>
{/* Full markdown block: `show` mode, or `hide` after the user opens it. */}
<box
id={"text-" + props.part.id}
paddingLeft={2}
marginTop={1}
flexDirection="column"
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.backgroundElement}
onMouseUp={toggle}
>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={subtleSyntax()}
content={(inMinimal() ? "▼ " : "") + (isDone() ? "_Thought:_ " : "_Thinking:_ ") + content()}
conceal={ctx.conceal()}
fg={theme.textMuted}
/>
</box>
</Match>
<Match when={isDone()}>
{/* Settled: ▶ at the start as the click-to-expand cue. */}
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
<text fg={theme.textMuted} wrapMode="none">
{"▶ " +
(title()
? "Thought: " + title() + " · " + Locale.duration(duration())
: "Thought for " + Locale.duration(duration()))}
</text>
</box>
</Match>
<Match when={true}>
{/* Streaming: leading animated spinner, no disclosure arrow yet it
snaps in once reasoning settles, signalling "done, click to expand". */}
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
<Spinner color={theme.textMuted}>{title() ? "Thinking: " + title() : "Thinking"}</Spinner>
</box>
</Match>
</Switch>
</Show>
)
}
@@ -1533,6 +1587,7 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
streaming={true}
internalBlockMode="top-level"
content={props.part.text.trim()}
tableOptions={{ style: "grid" }}
conceal={ctx.conceal()}
fg={theme.markdownText}
bg={theme.background}
@@ -38,6 +38,11 @@ export interface DialogSelectProps<T> {
disabled?: boolean
onTrigger: (option: DialogSelectOption<T>) => void
}[]
footerHints?: {
title: string
label: string
side?: "left" | "right"
}[]
bindings?: readonly Binding<Renderable, KeyEvent>[]
current?: T
}
@@ -334,11 +339,12 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}
props.ref?.(ref)
const visibleActions = createMemo(() =>
actions()
const visibleActions = createMemo(() => [
...actions()
.map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" }))
.filter((item) => !item.disabled && item.label),
)
...(props.footerHints ?? []),
])
const left = createMemo(() => visibleActions().filter((item) => item.side !== "right"))
const right = createMemo(() => visibleActions().filter((item) => item.side === "right"))
+2 -7
View File
@@ -2,7 +2,6 @@ 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 { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
@@ -77,12 +76,8 @@ export const rpc = {
return { url: server.url.toString() }
},
async checkUpgrade(input: { directory: string }) {
await WithInstance.provide({
directory: input.directory,
fn: async () => {
await upgrade().catch(() => {})
},
})
await InstanceRuntime.load({ directory: input.directory })
await upgrade().catch(() => {})
},
async reload() {
await AppRuntime.runPromise(
+1 -10
View File
@@ -3,7 +3,6 @@ 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"
/**
@@ -83,19 +82,11 @@ export const effectCmd = <Args, A>(opts: EffectCmdOpts<Args, A>) =>
return
}
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))),
)
await AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx)))
} finally {
await AppRuntime.runPromise(store.dispose(ctx))
}
+14 -15
View File
@@ -2,16 +2,9 @@ import { NamedError } from "@opencode-ai/core/util/error"
import { errorFormat } from "@/util/error"
import { isRecord } from "@/util/record"
interface ErrorLike {
name?: string
_tag?: string
message?: string
data?: Record<string, unknown>
}
type ConfigIssue = { message: string; path: string[] }
function isTaggedError(error: unknown, tag: string): boolean {
function isTaggedError(error: unknown, tag: string): error is Record<string, unknown> {
return isRecord(error) && error._tag === tag
}
@@ -39,22 +32,27 @@ function configIssues(input: Record<string, unknown>): ConfigIssue[] {
: []
}
export function FormatError(input: unknown) {
export function FormatError(input: unknown): string | undefined {
if (input instanceof Error && isRecord(input.cause) && "body" in input.cause) {
const formatted = FormatError(input.cause.body)
if (formatted) return formatted
}
// CliError: domain failure surfaced from an effectCmd handler via fail("...")
if (isTaggedError(input, "CliError")) {
const data = input as ErrorLike & { exitCode?: number }
if (data.exitCode != null) process.exitCode = data.exitCode
return data.message ?? ""
if (typeof input.exitCode === "number") process.exitCode = input.exitCode
return stringField(input, "message") ?? ""
}
// MCPFailed: { name: string }
if (NamedError.hasName(input, "MCPFailed")) {
return `MCP server "${(input as ErrorLike).data?.name}" failed. Note, opencode does not support MCP authentication yet.`
const data = isRecord(input) && isRecord(input.data) ? stringField(input.data, "name") : undefined
return `MCP server "${data}" failed. Note, opencode does not support MCP authentication yet.`
}
// AccountServiceError, AccountTransportError: TaggedErrorClass
if (isTaggedError(input, "AccountServiceError") || isTaggedError(input, "AccountTransportError")) {
return (input as ErrorLike).message ?? ""
return stringField(input, "message") ?? ""
}
// ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
@@ -64,7 +62,7 @@ export function FormatError(input: unknown) {
? providerModelNotFound.suggestions.filter((x) => typeof x === "string")
: []
return [
`Model not found: ${providerModelNotFound.providerID}/${providerModelNotFound.modelID}`,
`Model not found: ${stringField(providerModelNotFound, "providerID")}/${stringField(providerModelNotFound, "modelID")}`,
...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
`Try: \`opencode models\` to list available models`,
`Or check your config (opencode.json) provider/model names`,
@@ -112,6 +110,7 @@ export function FormatError(input: unknown) {
if (isTaggedError(input, "UICancelledError") || NamedError.hasName(input, "UICancelledError")) {
return ""
}
return undefined
}
export function FormatUnknownError(input: unknown): string {
+24 -4
View File
@@ -1,9 +1,9 @@
import { Bus } from "@/bus"
import { Config } from "@/config/config"
import { AppRuntime } from "@/effect/app-runtime"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Installation } from "@/installation"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { GlobalBus } from "@/bus/global"
export async function upgrade() {
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
@@ -13,7 +13,13 @@ export async function upgrade() {
if (!latest) return
if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) {
await Bus.publish(Installation.Event.UpdateAvailable, { version: latest })
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Installation.Event.UpdateAvailable.type,
properties: { version: latest },
},
})
return
}
@@ -22,12 +28,26 @@ export async function upgrade() {
const kind = Installation.getReleaseType(InstallationVersion, latest)
if (config.autoupdate === "notify" || kind !== "patch") {
await Bus.publish(Installation.Event.UpdateAvailable, { version: latest })
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Installation.Event.UpdateAvailable.type,
properties: { version: latest },
},
})
return
}
if (method === "unknown") return
await Installation.upgrade(method, latest)
.then(() => Bus.publish(Installation.Event.Updated, { version: latest }))
.then(() =>
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Installation.Event.Updated.type,
properties: { version: latest },
},
}),
)
.catch(() => {})
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { EffectBridge } from "@/effect/bridge"
import type { InstanceContext } from "@/project/instance"
import type { InstanceContext } from "@/project/instance-context"
import { SessionID, MessageID } from "@/session/schema"
import { Effect, Layer, Context, Schema } from "effect"
import { Config } from "@/config/config"
+2 -14
View File
@@ -1,10 +1,8 @@
export * as ConfigAgent from "./agent"
import { Exit, Schema, SchemaGetter } from "effect"
import { Bus } from "@/bus"
import { PositiveInt } from "@opencode-ai/core/schema"
import * as Log from "@opencode-ai/core/util/log"
import { NamedError } from "@opencode-ai/core/util/error"
import { Glob } from "@opencode-ai/core/util/glob"
import { configEntryNameFromPath } from "./entry-name"
import * as ConfigMarkdown from "./markdown"
@@ -112,12 +110,7 @@ export async function load(dir: string) {
dot: true,
symlink: true,
})) {
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse agent ${item}`
const { Session } = await import("@/session/session")
void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
const md = await ConfigMarkdown.parse(item).catch((err) => {
log.error("failed to load agent", { agent: item, err })
return undefined
})
@@ -144,12 +137,7 @@ export async function loadMode(dir: string) {
dot: true,
symlink: true,
})) {
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse mode ${item}`
const { Session } = await import("@/session/session")
void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
const md = await ConfigMarkdown.parse(item).catch((err) => {
log.error("failed to load mode", { mode: item, err })
return undefined
})
+1 -8
View File
@@ -2,9 +2,7 @@ export * as ConfigCommand from "./command"
import * as Log from "@opencode-ai/core/util/log"
import { Cause, Exit, Schema } from "effect"
import { NamedError } from "@opencode-ai/core/util/error"
import { Glob } from "@opencode-ai/core/util/glob"
import { Bus } from "@/bus"
import { configEntryNameFromPath } from "./entry-name"
import { InvalidError } from "./error"
import * as ConfigMarkdown from "./markdown"
@@ -32,12 +30,7 @@ export async function load(dir: string) {
dot: true,
symlink: true,
})) {
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse command ${item}`
const { Session } = await import("@/session/session")
void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
const md = await ConfigMarkdown.parse(item).catch((err) => {
log.error("failed to load command", { command: item, err })
return undefined
})
+1 -2
View File
@@ -10,7 +10,6 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { Auth } from "../auth"
import { Env } from "../env"
import { applyEdits, modify } from "jsonc-parser"
import { type InstanceContext } from "../project/instance"
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
import { existsSync } from "fs"
import { Account } from "@/account/account"
@@ -20,7 +19,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { InstanceState } from "@/effect/instance-state"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { containsPath } from "../project/instance-context"
import { containsPath, type InstanceContext } from "../project/instance-context"
import { NonNegativeInt, PositiveInt, type DeepMutable } from "@opencode-ai/core/schema"
import { ConfigAgent } from "./agent"
import { ConfigAttachment } from "./attachment"
@@ -1,5 +1,6 @@
import { Schema } from "effect"
import { type WorkspaceAdapter, WorkspaceInfo } from "../types"
import { Effect, Schema } from "effect"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { type WorkspaceAdapter, type WorkspaceAdapterContext, WorkspaceInfo } from "../types"
const WorktreeConfig = Schema.Struct({
name: WorkspaceInfo.fields.name,
@@ -9,53 +10,81 @@ const WorktreeConfig = Schema.Struct({
const decodeWorktreeConfig = Schema.decodeUnknownSync(WorktreeConfig)
async function loadWorktree() {
const [{ AppRuntime }, { Instance }, { Worktree }] = await Promise.all([
import("@/effect/app-runtime"),
import("@/project/instance"),
import("@/worktree"),
])
return { AppRuntime, Instance, Worktree }
const [{ AppRuntime }, { Worktree }] = await Promise.all([import("@/effect/app-runtime"), import("@/worktree")])
return { AppRuntime, Worktree }
}
function requireInstance(context: WorkspaceAdapterContext | undefined) {
if (!context?.instance) throw new Error("Worktree adapter requires an instance context")
return context.instance
}
const provideContext = <A, E, R>(effect: Effect.Effect<A, E, R>, context: WorkspaceAdapterContext | undefined) =>
effect.pipe(
Effect.provideService(InstanceRef, requireInstance(context)),
Effect.provideService(WorkspaceRef, context?.workspaceID),
)
export const WorktreeAdapter: WorkspaceAdapter = {
name: "Worktree",
description: "Create a git worktree",
async configure(info) {
async configure(info, context) {
const { AppRuntime, Worktree } = await loadWorktree()
const next = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo({ detached: true })))
const next = await AppRuntime.runPromise(
provideContext(
Worktree.Service.use((svc) => svc.makeWorktreeInfo({ detached: true })),
context,
),
)
return {
...info,
name: next.name,
directory: next.directory,
}
},
async create(info) {
async create(info, _env, _from, context) {
const { AppRuntime, Worktree } = await loadWorktree()
const config = decodeWorktreeConfig(info)
await AppRuntime.runPromise(
Worktree.Service.use((svc) =>
svc.createFromInfo({
name: config.name,
directory: config.directory,
...(config.branch ? { branch: config.branch } : {}),
}),
provideContext(
Worktree.Service.use((svc) =>
svc.createFromInfo({
name: config.name,
directory: config.directory,
...(config.branch ? { branch: config.branch } : {}),
}),
),
context,
),
)
},
async list() {
const { AppRuntime, Instance, Worktree } = await loadWorktree()
return (await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.list()))).map((info) => ({
async list(context) {
const { AppRuntime, Worktree } = await loadWorktree()
const ctx = requireInstance(context)
return (
await AppRuntime.runPromise(
provideContext(
Worktree.Service.use((svc) => svc.list()),
context,
),
)
).map((info) => ({
type: "worktree",
name: info.name,
branch: info.branch,
directory: info.directory,
projectID: Instance.project.id,
projectID: ctx.project.id,
}))
},
async remove(info) {
async remove(info, context) {
const { AppRuntime, Worktree } = await loadWorktree()
const config = decodeWorktreeConfig(info)
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove({ directory: config.directory })))
await AppRuntime.runPromise(
provideContext(
Worktree.Service.use((svc) => svc.remove({ directory: config.directory })),
context,
),
)
},
target(info) {
const config = decodeWorktreeConfig(info)
+16 -5
View File
@@ -1,5 +1,6 @@
import { Schema, Struct } from "effect"
import { ProjectID } from "@/project/schema"
import type { InstanceContext } from "@/project/instance-context"
import { WorkspaceID } from "./schema"
import type { DeepMutable } from "@opencode-ai/core/schema"
@@ -37,12 +38,22 @@ export type Target =
headers?: HeadersInit
}
export type WorkspaceAdapterContext = {
readonly instance?: InstanceContext
readonly workspaceID?: WorkspaceID
}
export type WorkspaceAdapter = {
name: string
description: string
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
create(info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo): Promise<void>
list?(): WorkspaceListedInfo[] | Promise<WorkspaceListedInfo[]>
remove(info: WorkspaceInfo): Promise<void>
target(info: WorkspaceInfo): Target | Promise<Target>
configure(info: WorkspaceInfo, context?: WorkspaceAdapterContext): WorkspaceInfo | Promise<WorkspaceInfo>
create(
info: WorkspaceInfo,
env: Record<string, string | undefined>,
from?: WorkspaceInfo,
context?: WorkspaceAdapterContext,
): Promise<void>
list?(context?: WorkspaceAdapterContext): WorkspaceListedInfo[] | Promise<WorkspaceListedInfo[]>
remove(info: WorkspaceInfo, context?: WorkspaceAdapterContext): Promise<void>
target(info: WorkspaceInfo, context?: WorkspaceAdapterContext): Target | Promise<Target>
}
@@ -0,0 +1,51 @@
import { Effect } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { getAdapter } from "./adapters"
import type { WorkspaceAdapter, WorkspaceInfo } from "./types"
const context = Effect.gen(function* () {
return {
instance: yield* InstanceRef,
workspaceID: yield* WorkspaceRef,
}
})
export const target = (info: WorkspaceInfo) =>
Effect.gen(function* () {
const adapter = getAdapter(info.projectID, info.type)
const ctx = yield* context
return yield* EffectBridge.fromPromise(() => adapter.target(info, ctx))
})
export const configure = (adapter: WorkspaceAdapter, info: WorkspaceInfo) =>
Effect.gen(function* () {
const ctx = yield* context
return yield* EffectBridge.fromPromise(() => adapter.configure(info, ctx))
})
export const create = (
adapter: WorkspaceAdapter,
info: WorkspaceInfo,
env: Record<string, string | undefined>,
from?: WorkspaceInfo,
) =>
Effect.gen(function* () {
const ctx = yield* context
return yield* EffectBridge.fromPromise(() => adapter.create(info, env, from, ctx))
})
export const list = (adapter: WorkspaceAdapter) =>
Effect.gen(function* () {
const ctx = yield* context
return yield* EffectBridge.fromPromise(() => Promise.resolve(adapter.list?.(ctx) ?? []))
})
export const remove = (info: WorkspaceInfo) =>
Effect.gen(function* () {
const adapter = getAdapter(info.projectID, info.type)
const ctx = yield* context
return yield* EffectBridge.fromPromise(() => adapter.remove(info, ctx))
})
export * as WorkspaceAdapterRuntime from "./workspace-adapter-runtime"
@@ -26,11 +26,11 @@ import { SessionID } from "@/session/schema"
import { NotFoundError } from "@/storage/storage"
import { errorData } from "@/util/error"
import { waitEvent } from "./util"
import { WorkspaceContext } from "./workspace-context"
import { EffectBridge } from "@/effect/bridge"
import { WorkspaceRef } from "@/effect/instance-ref"
import { Vcs } from "@/project/vcs"
import { InstanceStore } from "@/project/instance-store"
import { InstanceBootstrap } from "@/project/bootstrap"
import { WorkspaceAdapterRuntime } from "./workspace-adapter-runtime"
export const Info = Schema.Struct({
...WorkspaceInfoSchema.fields,
@@ -281,8 +281,7 @@ export const layer = Layer.effect(
const workspace = yield* get(input.workspaceID)
if (!workspace) return input.fallback
const adapter = getAdapter(workspace.projectID, workspace.type)
const target = yield* EffectBridge.fromPromise(() => adapter.target(workspace))
const target = yield* WorkspaceAdapterRuntime.target(workspace)
if (target.type === "local") {
const store = yield* InstanceStore.Service
@@ -375,35 +374,27 @@ export const layer = Layer.effect(
events: events.length,
})
yield* Effect.promise(async () => {
await WorkspaceContext.provide({
workspaceID: space.id,
async fn() {
await Effect.runPromise(
Effect.forEach(
events,
(event) =>
sync.replay(
{
id: event.id,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
},
{ publish: true },
),
{ discard: true },
),
yield* Effect.forEach(
events,
(event) =>
sync
.replay(
{
id: event.id,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
},
{ publish: true },
)
},
})
})
.pipe(Effect.provideService(WorkspaceRef, space.id)),
{ discard: true },
)
})
const syncWorkspaceLoop = Effect.fn("Workspace.syncWorkspaceLoop")(function* (space: Info) {
const adapter = getAdapter(space.projectID, space.type)
const target = yield* EffectBridge.fromPromise(() => adapter.target(space))
const target = yield* WorkspaceAdapterRuntime.target(space)
if (target.type === "local") return
@@ -486,8 +477,7 @@ export const layer = Layer.effect(
const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) {
if (!flags.experimentalWorkspaces) return
const adapter = getAdapter(space.projectID, space.type)
const target = yield* EffectBridge.fromPromise(() => adapter.target(space)).pipe(
const target = yield* WorkspaceAdapterRuntime.target(space).pipe(
Effect.catch((error) =>
Effect.sync(() => {
setStatus(space.id, "error")
@@ -538,15 +528,13 @@ export const layer = Layer.effect(
const create = Effect.fn("Workspace.create")(function* (input: CreateInput) {
const id = WorkspaceID.ascending(input.id)
const adapter = getAdapter(input.projectID, input.type)
const config = yield* EffectBridge.fromPromise(() =>
adapter.configure({
...input,
id,
name: Slug.create(),
directory: null,
extra: input.extra ?? null,
}),
)
const config = yield* WorkspaceAdapterRuntime.configure(adapter, {
...input,
id,
name: Slug.create(),
directory: null,
extra: input.extra ?? null,
})
const info: Info = {
id,
@@ -583,7 +571,7 @@ export const layer = Layer.effect(
OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES,
}
yield* EffectBridge.fromPromise(() => adapter.create(config, env))
yield* WorkspaceAdapterRuntime.create(adapter, config, env)
yield* Effect.all(
[
waitEvent({
@@ -622,8 +610,7 @@ export const layer = Layer.effect(
if (current?.workspaceID) {
const previous = yield* get(current.workspaceID)
if (previous) {
const adapter = getAdapter(previous.projectID, previous.type)
const target = yield* EffectBridge.fromPromise(() => adapter.target(previous))
const target = yield* WorkspaceAdapterRuntime.target(previous)
if (target.type === "remote") {
yield* syncHistory(previous, target.url, target.headers).pipe(
@@ -701,8 +688,7 @@ export const layer = Layer.effect(
workspaceID,
})
const adapter = getAdapter(space.projectID, space.type)
const target = yield* EffectBridge.fromPromise(() => adapter.target(space))
const target = yield* WorkspaceAdapterRuntime.target(space)
if (target.type === "local") {
yield* sync.run(Session.Event.Updated, {
@@ -855,16 +841,14 @@ export const layer = Layer.effect(
const discovered = yield* Effect.forEach(
registeredAdapters(project.id),
([type, adapter]) =>
adapter.list
? EffectBridge.fromPromise(() => Promise.resolve(adapter.list?.() ?? [])).pipe(
Effect.catchCause((error) =>
Effect.sync(() => {
log.warn("workspace adapter list failed", { type, error })
return []
}),
),
)
: Effect.succeed([]),
WorkspaceAdapterRuntime.list(adapter).pipe(
Effect.catchCause((error) =>
Effect.sync(() => {
log.warn("workspace adapter list failed", { type, error })
return []
}),
),
),
{ concurrency: "unbounded" },
).pipe(Effect.map((items) => items.flat()))
@@ -937,8 +921,7 @@ export const layer = Layer.effect(
const info = fromRow(row)
yield* Effect.catchCause(
Effect.gen(function* () {
const adapter = getAdapter(info.projectID, row.type)
yield* EffectBridge.fromPromise(() => adapter.remove(info))
yield* WorkspaceAdapterRuntime.remove(info)
}),
() =>
Effect.sync(() => {
+41 -35
View File
@@ -1,8 +1,6 @@
import { Effect, Exit, Fiber } from "effect"
import { Context, Effect, Exit, Fiber } from "effect"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { Instance, type InstanceContext } from "@/project/instance"
import type { WorkspaceID } from "@/control-plane/schema"
import { LocalContext } from "@/util/local-context"
import { InstanceRef, WorkspaceRef } from "./instance-ref"
import { attachWith } from "./run-service"
@@ -10,67 +8,75 @@ export interface Shape {
readonly promise: <A, E, R>(effect: Effect.Effect<A, E, R>) => Promise<A>
readonly fork: <A, E, R>(effect: Effect.Effect<A, E, R>) => Fiber.Fiber<A, E>
readonly run: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E>
readonly bind: <Args extends readonly unknown[], Result>(fn: (...args: Args) => Result) => (...args: Args) => Result
}
function restore<R>(instance: InstanceContext | undefined, workspace: WorkspaceID | undefined, fn: () => R): R {
if (instance && workspace !== undefined) {
return WorkspaceContext.restore(workspace, () => Instance.restore(instance, fn))
}
if (instance) return Instance.restore(instance, fn)
function restoreWorkspace<R>(workspace: WorkspaceID | undefined, fn: () => R): R {
if (workspace !== undefined) return WorkspaceContext.restore(workspace, fn)
return fn()
}
function captureSync() {
const fiber = Fiber.getCurrent()
const instance = fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined
const workspace =
(fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined) ?? WorkspaceContext.workspaceID
return { instance, workspace }
}
export const bind = <Args extends readonly unknown[], Result>(fn: (...args: Args) => Result) => {
const captured = captureSync()
return (...args: Args) =>
restoreWorkspace(captured.workspace, () =>
Effect.runSync(
attachWith(
Effect.sync(() => fn(...args)),
captured,
),
),
)
}
/**
* Bridge from Effect into a Promise-returning JS callback while installing
* legacy `Instance.context` and `WorkspaceContext` AsyncLocalStorage for
* the duration of the callback. Effect's `InstanceRef`/`WorkspaceRef` do
* not propagate across async/await boundaries inside `Effect.promise(() =>
* async fn)` callbacks that re-enter Effect via `AppRuntime.runPromise`,
* but Node's AsyncLocalStorage does. Use this whenever an Effect crosses
* into JS that may itself spawn new Effect runtimes (workspace adapters,
* legacy plugins, etc.).
* Bridge from Effect into a Promise-returning JS callback while preserving
* `WorkspaceContext` AsyncLocalStorage for callback code that still reads it.
* `InstanceRef` is captured for effects run through the returned bridge APIs;
* plain JS callbacks that need it should receive the ref explicitly.
*
* Mirrors `Effect.promise` but restores legacy ALS first.
* Mirrors `Effect.promise` but restores workspace ALS first.
*/
export const fromPromise = <T>(fn: () => Promise<T> | T): Effect.Effect<T> =>
Effect.gen(function* () {
const instance = yield* InstanceRef
const workspace = yield* WorkspaceRef
return yield* Effect.promise(() => Promise.resolve(restore(instance, workspace, () => fn())))
return yield* Effect.promise(() => Promise.resolve(restoreWorkspace(workspace, () => fn())))
})
export function make(): Effect.Effect<Shape> {
return Effect.gen(function* () {
const ctx = yield* Effect.context()
const value = yield* InstanceRef
const instance =
value ??
(() => {
try {
return Instance.current
} catch (err) {
if (!(err instanceof LocalContext.NotFound)) throw err
}
})()
const workspace = (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID
const attach = <A, E, R>(effect: Effect.Effect<A, E, R>) => attachWith(effect, { instance, workspace })
const captured = captureSync()
const instance = (yield* InstanceRef) ?? captured.instance
const workspace = (yield* WorkspaceRef) ?? captured.workspace
const wrap = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
attach(effect).pipe(Effect.provide(ctx)) as Effect.Effect<A, E, never>
attachWith(effect.pipe(Effect.provide(ctx)) as Effect.Effect<A, E, never>, { instance, workspace })
return {
promise: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
restore(instance, workspace, () => Effect.runPromise(wrap(effect))),
restoreWorkspace(workspace, () => Effect.runPromise(wrap(effect))),
fork: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
restore(instance, workspace, () => Effect.runFork(wrap(effect))),
restoreWorkspace(workspace, () => Effect.runFork(wrap(effect))),
run: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.callback<A, E>((resume) => {
restore(instance, workspace, () =>
restoreWorkspace(workspace, () =>
Effect.runPromiseExit(wrap(effect)).then((exit) =>
resume(Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause)),
),
)
}),
bind:
<Args extends readonly unknown[], Result>(fn: (...args: Args) => Result) =>
(...args: Args) =>
restoreWorkspace(workspace, () => Effect.runSync(wrap(Effect.sync(() => fn(...args))))),
} satisfies Shape
})
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { Context } from "effect"
import type { InstanceContext } from "@/project/instance"
import type { InstanceContext } from "@/project/instance-context"
import type { WorkspaceID } from "@/control-plane/schema"
export const InstanceRef = Context.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {

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