mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 16:56:33 -04:00
Compare commits
109 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53242d581b | |||
| 7d85813a0f | |||
| ef96fdfd83 | |||
| 241b7b5792 | |||
| 53d45b2894 | |||
| 9d11b62ff0 | |||
| 1c453bd4e1 | |||
| 4f455f1869 | |||
| d33517c6b5 | |||
| 99c758e343 | |||
| 27de1ee982 | |||
| 8e0676ebea | |||
| 990a705063 | |||
| 33a78ead3d | |||
| 3739eee8cf | |||
| 816f5e526b | |||
| 63abe037d8 | |||
| f2733330f7 | |||
| c41dc21292 | |||
| f80bf7a423 | |||
| 13ea36f575 | |||
| 02b7367b4e | |||
| aa4cf681a9 | |||
| f1c0b2651a | |||
| eab9a4f564 | |||
| 012fc184bc | |||
| 0ed88cfc21 | |||
| 98689fb125 | |||
| 62bd7c82d3 | |||
| f98449c9b5 | |||
| 468eb68878 | |||
| 23b594de6e | |||
| 5060577ee1 | |||
| d3ebb1f7c0 | |||
| e4cc4e1682 | |||
| 53e89f9d52 | |||
| de247b7aae | |||
| 0b8050d453 | |||
| be6a89a3b8 | |||
| c0a8b509c7 | |||
| f80651fa91 | |||
| 68af95390d | |||
| 321db7a819 | |||
| 6d2219e001 | |||
| dd432e3cde | |||
| 77db212a0a | |||
| f56263791c | |||
| 88363f1ed9 | |||
| c5db39f626 | |||
| b5aed287ca | |||
| 53849bd866 | |||
| e33912bfee | |||
| 548648a3d9 | |||
| 4643e13170 | |||
| 042e6a5c86 | |||
| e36d6a0cbe | |||
| cc9c0b15c7 | |||
| f3b0d3d7ac | |||
| 764c6bc517 | |||
| d441e931f9 | |||
| ad79ad9ea8 | |||
| d6b23fd8f6 | |||
| 5911bd532d | |||
| 2385123f03 | |||
| 09549661e1 | |||
| da495fd2e0 | |||
| 85cd447910 | |||
| 0f31fd631b | |||
| aa07e21945 | |||
| f060874b29 | |||
| f21c582db9 | |||
| 65f96a5851 | |||
| 48122b31cc | |||
| 0df2f5b45f | |||
| 499e8e4b78 | |||
| f33b4455a1 | |||
| a24abd2b11 | |||
| d44bef2107 | |||
| f99339e525 | |||
| 2b0e72ab79 | |||
| 2fdee50b3b | |||
| 48293c5271 | |||
| 0c9cfe923f | |||
| 9975c1ed1c | |||
| ef7d801271 | |||
| eb630075c3 | |||
| a2392ca60d | |||
| f9371eb66c | |||
| fa9a2cb24d | |||
| 2d90f325fc | |||
| c2ffd7cf14 | |||
| 104f5d5a14 | |||
| 1c7c03332e | |||
| 984eefa6f8 | |||
| bf64f8cbb5 | |||
| 727a83aa7a | |||
| e65383810a | |||
| 12b666e2c9 | |||
| eb5ef1c073 | |||
| 356f684186 | |||
| 7b370406a9 | |||
| 202cc863b4 | |||
| 22cb0395e2 | |||
| 2d6bedecd4 | |||
| 2080390ca6 | |||
| 1ac3f09468 | |||
| ca8f578f2f | |||
| d59d99665b | |||
| c43edc5b71 |
@@ -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[@]}"
|
||||
@@ -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(`=============================`)
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
- ci
|
||||
- dev
|
||||
- beta
|
||||
- fix/npm-native-binary-install
|
||||
- snapshot-*
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {},
|
||||
"permission": {},
|
||||
"mcp": {},
|
||||
"mcp": {
|
||||
"opencode": {
|
||||
"type": "remote",
|
||||
"url": "http://127.0.0.1:43110/mcp"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"github-triage": false,
|
||||
"github-pr-search": false,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 l’evoluzione dei modelli, le differenze tra di essi si ridurranno e i prezzi scenderanno, quindi essere indipendenti dal provider è importante.
|
||||
- Supporto LSP pronto all’uso
|
||||
- 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 un’app 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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
# Effect Service Dependency Graph — Simulated Routes
|
||||
|
||||
Generated for `createSimulatedRoutes` in `packages/opencode/src/server/routes/instance/httpapi/server.ts`.
|
||||
|
||||
## Notation
|
||||
|
||||
- `→ X` means "yields `X.Service` from its `Effect.gen` body at layer init (a true `RIn` of `.layer`)"
|
||||
- `(lazy)` means "uses `InstanceState.context` or similar at call time, not at layer construction"
|
||||
- `(opt)` means "uses `Effect.serviceOption(X)` — not strictly required"
|
||||
- `(internal)` means "satisfied internally by the `.layer` itself via `Layer.provide(...)`, NOT a residual requirement"
|
||||
|
||||
## Service → Dependencies (current, post-rebase)
|
||||
|
||||
```
|
||||
─── External / Platform ─────────────────────────────────────────
|
||||
NodePath (no app deps) provides Path.Path
|
||||
FetchHttpClient (no app deps) provides HttpClient.HttpClient
|
||||
HttpServer.layerServices (no app deps)
|
||||
ChildProcessSpawner (from SimulationSpawner) (no app deps)
|
||||
|
||||
─── Leaf services (no app deps) ─────────────────────────────────
|
||||
Global (no app deps)
|
||||
Env (no app deps — uses InstanceState.make, no Service yields)
|
||||
Bus (no app deps — uses InstanceState.make, no Service yields)
|
||||
SyncEvent → RuntimeFlags, Bus (NEW — previously listed as leaf/lazy)
|
||||
AccountRepo (no app deps — pure DB closures)
|
||||
PtyTicket (no app deps — Cache only)
|
||||
Truncate → AppFileSystem
|
||||
|
||||
─── Middleware/route layers (no app service deps) ───────────────
|
||||
errorLayer
|
||||
compressionLayer → HttpServerRequest (builtin)
|
||||
corsVaryFix
|
||||
fenceLayer → HttpServerRequest (builtin)
|
||||
simulationShareNextLayer provides ShareNext (Layer.succeed override)
|
||||
|
||||
─── Simulation overrides ────────────────────────────────────────
|
||||
SimulationFileSystem provides AppFileSystem (does NOT provide FileSystem.FileSystem;
|
||||
tier0 also merges FileSystem.layerNoop({}) for that tag)
|
||||
SimulationSpawner provides ChildProcessSpawner (Layer.succeed; no deps)
|
||||
SimulationNetwork provides SimulationNetwork.Service + HttpClient.HttpClient
|
||||
(httpClientLayer composed inside `layer(options)`)
|
||||
SimulationGit → AppFileSystem (overrides Git tag)
|
||||
SimulationProvider → Simulation (overrides Provider tag)
|
||||
Simulation → AppFileSystem, SimulationNetwork
|
||||
|
||||
─── Core services ───────────────────────────────────────────────
|
||||
EffectFlock → Global, AppFileSystem
|
||||
Auth → AppFileSystem
|
||||
McpAuth → AppFileSystem
|
||||
Account → AccountRepo, HttpClient
|
||||
Npm → AppFileSystem, Global, FileSystem.FileSystem, EffectFlock
|
||||
Config → AppFileSystem, Auth, Account, Env, Npm
|
||||
Permission → Bus
|
||||
Plugin → Bus, Config, RuntimeFlags (NEW: RuntimeFlags)
|
||||
Discovery → AppFileSystem, Path, HttpClient
|
||||
Skill → Discovery, Config, Bus, AppFileSystem, Global,
|
||||
RuntimeFlags (NEW: RuntimeFlags)
|
||||
SystemPrompt → Skill
|
||||
|
||||
─── File / git ──────────────────────────────────────────────────
|
||||
Ripgrep → AppFileSystem, HttpClient, ChildProcessSpawner
|
||||
File → AppFileSystem, Ripgrep, Git, Scope
|
||||
FileWatcher → Config, Git
|
||||
Format → Config, AppProcess, RuntimeFlags (CHANGED: was ChildProcessSpawner; now AppProcess + RuntimeFlags)
|
||||
Snapshot → AppFileSystem, AppProcess, Config (CHANGED: was ChildProcessSpawner)
|
||||
Storage → AppFileSystem, Git
|
||||
Vcs → Git, Bus, Scope
|
||||
Worktree → Scope, AppFileSystem, Path, AppProcess,
|
||||
Git, Project, InstanceStore (CHANGED: AppProcess instead of ChildProcessSpawner)
|
||||
Project → AppFileSystem, Path, ChildProcessSpawner,
|
||||
Bus, RuntimeFlags (NEW: RuntimeFlags)
|
||||
|
||||
─── Provider / LSP / MCP ────────────────────────────────────────
|
||||
ModelsDev → AppFileSystem, HttpClient
|
||||
ProviderAuth → Auth, Plugin
|
||||
LSP → Config, RuntimeFlags (NEW: RuntimeFlags)
|
||||
McpAuth → AppFileSystem
|
||||
MCP → ChildProcessSpawner, McpAuth, Bus, Config
|
||||
|
||||
─── Session graph ───────────────────────────────────────────────
|
||||
Todo → Bus
|
||||
Question → Bus
|
||||
SessionStatus → Bus
|
||||
SessionRunState → BackgroundJob, SessionStatus (NEW: BackgroundJob)
|
||||
Instruction → Config, AppFileSystem, Global, HttpClient,
|
||||
RuntimeFlags (NEW: RuntimeFlags)
|
||||
|
||||
Session → BackgroundJob, Bus, Storage, SyncEvent,
|
||||
RuntimeFlags (NEW: BackgroundJob, RuntimeFlags)
|
||||
SessionSummary → Session, Snapshot, Storage, Bus
|
||||
|
||||
SessionRevert → Session, Snapshot, Storage, Bus,
|
||||
SessionSummary, SessionRunState, SyncEvent
|
||||
LLM → Auth, Config, Provider, Plugin, RuntimeFlags (CHANGED: Permission satisfied internally;
|
||||
(Permission satisfied internally via .layer) RuntimeFlags is new)
|
||||
|
||||
Agent → Config, Auth, Plugin, Skill, Provider,
|
||||
RuntimeFlags (NEW: RuntimeFlags)
|
||||
Command → Config, MCP, Skill
|
||||
|
||||
SessionProcessor → Session, Config, Bus, Snapshot, Agent, LLM,
|
||||
Permission, Plugin, SessionSummary,
|
||||
SessionStatus, Image, EventV2Bridge,
|
||||
RuntimeFlags, Scope (NEW: Image, EventV2Bridge, RuntimeFlags)
|
||||
SessionCompaction → Bus, Config, Session, Agent, Plugin,
|
||||
SessionProcessor, Provider, EventV2Bridge,
|
||||
RuntimeFlags (NEW: EventV2Bridge, RuntimeFlags)
|
||||
SessionPrompt → Bus, SessionStatus, Session, Agent, Provider,
|
||||
SessionProcessor, SessionCompaction, Plugin,
|
||||
Command, Config, Permission, AppFileSystem,
|
||||
MCP, LSP, ToolRegistry, Truncate,
|
||||
ChildProcessSpawner, Scope, Instruction,
|
||||
SessionRunState, SessionRevert,
|
||||
SessionSummary, SystemPrompt, LLM,
|
||||
Image, Reference, EventV2Bridge,
|
||||
RuntimeFlags (NEW: Image, Reference, EventV2Bridge, RuntimeFlags)
|
||||
|
||||
ToolRegistry → Config, Plugin, Question, Todo, Agent, Skill,
|
||||
Session, SessionStatus, BackgroundJob,
|
||||
Provider, Git, Reference, LSP, Instruction,
|
||||
AppFileSystem, Bus, HttpClient,
|
||||
ChildProcessSpawner, Ripgrep, Format,
|
||||
Truncate, RuntimeFlags (NEW: BackgroundJob, Reference, RuntimeFlags)
|
||||
|
||||
─── Share / Workspace ───────────────────────────────────────────
|
||||
ShareNext (provided by simulationShareNextLayer in sim)
|
||||
SessionShare → Config, Session, ShareNext, Scope, SyncEvent,
|
||||
RuntimeFlags (NEW: RuntimeFlags)
|
||||
Workspace → Auth, Session, SessionPrompt, HttpClient,
|
||||
SyncEvent, Vcs, AppFileSystem, RuntimeFlags (NEW: AppFileSystem (explicit), RuntimeFlags)
|
||||
|
||||
─── Misc ────────────────────────────────────────────────────────
|
||||
Installation → HttpClient, AppProcess (CHANGED: AppProcess instead of ChildProcessSpawner)
|
||||
Pty → Config, Bus, Plugin
|
||||
|
||||
─── Instance lifecycle ──────────────────────────────────────────
|
||||
InstanceBootstrap → Config, File, FileWatcher, Format, LSP, Plugin,
|
||||
Project, Reference, ShareNext, Snapshot, Vcs (NEW: Reference)
|
||||
InstanceStore → Project, InstanceBootstrap, Scope
|
||||
|
||||
Observability (no app deps; provides Logger + tracer)
|
||||
```
|
||||
|
||||
## NEW dependencies introduced since previous graph
|
||||
|
||||
The rebase pulled in several new cross-cutting services that need to be
|
||||
satisfied somewhere in tier0/tier1 of the simulated chain. They are NOT yet
|
||||
listed in the `Tier*Services` unions or merged into any tier in `server.ts`:
|
||||
|
||||
```
|
||||
RuntimeFlags.Service — yielded by ~17 services (Plugin, Skill, Project,
|
||||
Session, SyncEvent, Format, LSP, Instruction,
|
||||
Agent, LLM, SessionShare, SessionProcessor,
|
||||
SessionCompaction, ToolRegistry, SessionPrompt,
|
||||
Workspace, SessionRunState).
|
||||
Source: `@/effect/runtime-flags`.
|
||||
|
||||
AppProcess.Service — yielded by Installation, Format, Snapshot,
|
||||
Worktree (and prod Git, but sim uses SimulationGit).
|
||||
Source: presumed `@opencode-ai/core/app-process`
|
||||
or similar — needs `AppProcess.defaultLayer`.
|
||||
|
||||
BackgroundJob.Service — yielded by Session, SessionRunState, ToolRegistry.
|
||||
Needs `BackgroundJob.defaultLayer`.
|
||||
|
||||
Image.Service — yielded by SessionProcessor, SessionPrompt.
|
||||
|
||||
EventV2Bridge.Service — yielded by SessionProcessor, SessionCompaction,
|
||||
SessionPrompt. Source: `@/event-v2-bridge` (already
|
||||
imported in server.ts but never added to a tier).
|
||||
|
||||
Reference.Service — yielded by ToolRegistry, SessionPrompt,
|
||||
InstanceBootstrap.
|
||||
```
|
||||
|
||||
## Dependency Tiers (topological order)
|
||||
|
||||
Roughly, build order from leaves to roots:
|
||||
|
||||
```
|
||||
Tier 0 (no deps):
|
||||
Global, Env, NodePath, AccountRepo, PtyTicket, Bus, SyncEvent,
|
||||
AppFileSystem (sim), ChildProcessSpawner (sim), HttpClient (sim),
|
||||
SimulationNetwork, FileSystem.layerNoop, simulationShareNextLayer
|
||||
+ (NEW REQUIRED) RuntimeFlags, AppProcess, BackgroundJob, Image,
|
||||
EventV2Bridge, Reference
|
||||
(SyncEvent now depends on RuntimeFlags + Bus, so it's actually tier 1.)
|
||||
|
||||
Tier 1:
|
||||
Auth, Truncate, EffectFlock, Permission, Todo, Question,
|
||||
SessionStatus, McpAuth, Discovery, SimulationGit, Ripgrep, Account,
|
||||
SyncEvent (needs RuntimeFlags + Bus)
|
||||
|
||||
Tier 2:
|
||||
Npm, ModelsDev, Project, Installation, Storage, Vcs, SessionRunState
|
||||
|
||||
Tier 3:
|
||||
Config, File, Simulation, Session
|
||||
|
||||
Tier 4:
|
||||
Plugin, FileWatcher, Format, Snapshot, LSP, MCP, Skill, Instruction,
|
||||
SimulationProvider (= Provider)
|
||||
|
||||
Tier 5:
|
||||
Pty, ProviderAuth, SessionSummary, Agent, Command, LLM, SystemPrompt
|
||||
|
||||
Tier 6:
|
||||
SessionRevert, SessionProcessor, SessionShare
|
||||
|
||||
Tier 7:
|
||||
SessionCompaction, ToolRegistry
|
||||
|
||||
Tier 8:
|
||||
SessionPrompt
|
||||
|
||||
Tier 9:
|
||||
Workspace, InstanceBootstrap
|
||||
|
||||
Tier 10:
|
||||
InstanceStore
|
||||
|
||||
Tier 11:
|
||||
Worktree
|
||||
```
|
||||
|
||||
## Potential cycles / hazards
|
||||
|
||||
```
|
||||
Worktree → InstanceStore → InstanceBootstrap → Project → (back to Worktree?)
|
||||
- InstanceBootstrap requires Project (yes)
|
||||
- Project does NOT require Worktree directly
|
||||
- Worktree requires InstanceStore at layer init
|
||||
→ Worktree must be built AFTER InstanceStore.
|
||||
|
||||
SimulationProvider provides Provider tag, depends on Simulation.
|
||||
Many downstream services depend on Provider — those resolve to
|
||||
SimulationProvider in this layer chain.
|
||||
|
||||
SimulationGit provides Git tag, used by File, FileWatcher,
|
||||
Storage, Vcs, Worktree, ToolRegistry tools.
|
||||
|
||||
LLM.layer pipes Layer.provide(Permission.defaultLayer) internally.
|
||||
So LLM's residual requirements no longer include Permission, BUT the
|
||||
sim chain still provides Permission.layer separately (correct — used by
|
||||
SessionProcessor, SessionPrompt directly).
|
||||
```
|
||||
|
||||
## Why the simulated chain fails today
|
||||
|
||||
The current `server.ts` `Tier0Services` union lists:
|
||||
```
|
||||
AppFileSystem, FileSystem.FileSystem, ChildProcessSpawner, HttpClient,
|
||||
SimulationNetwork, Path, Global, Env, Bus, AccountRepo, ShareNext,
|
||||
SyncEvent, PtyTicket
|
||||
```
|
||||
|
||||
But the actual `Layer.mergeAll(...)` body at tier0 has residual requirements
|
||||
beyond that union. The TS error says
|
||||
`Layer<..., never, Service | Service>` — those two unresolved `Service`s
|
||||
are members of the NEW dependencies table above.
|
||||
|
||||
The most likely culprits, in order of leakage:
|
||||
|
||||
1. **`SyncEvent.layer`** now yields `RuntimeFlags` and `Bus`. `Bus` is in tier0
|
||||
already, but `RuntimeFlags` is not provided anywhere in `createSimulatedRoutes`.
|
||||
So `SyncEvent` leaks `RuntimeFlags` into tier0's `RIn`.
|
||||
|
||||
2. Downstream tiers also leak `RuntimeFlags`, `AppProcess`, `BackgroundJob`,
|
||||
`Image`, `EventV2Bridge`, `Reference` — these cascade through every higher
|
||||
tier as `Service | Service | ...` in the error type.
|
||||
|
||||
## Fix strategy
|
||||
|
||||
The minimal change to make tier0 type-check:
|
||||
|
||||
1. Add `RuntimeFlags.defaultLayer` to tier0 and `RuntimeFlags.Service` to
|
||||
`Tier0Services`. This satisfies `SyncEvent`'s new dep and unblocks every
|
||||
service that yields `RuntimeFlags`.
|
||||
2. Add `AppProcess.defaultLayer` (or a simulated equivalent) to tier0 and
|
||||
`AppProcess.Service` to `Tier0Services`. Needed by `Installation`, `Format`,
|
||||
`Snapshot`, `Worktree`.
|
||||
3. Add `BackgroundJob.defaultLayer` to tier0 and `BackgroundJob.Service` to
|
||||
`Tier0Services`. Needed by `Session`, `SessionRunState`, `ToolRegistry`.
|
||||
4. Add `Image.defaultLayer` to tier0 (or wherever it fits) and `Image.Service`
|
||||
to `Tier0Services`. Needed by `SessionProcessor`, `SessionPrompt`.
|
||||
5. Add `EventV2Bridge.defaultLayer` to tier0 and `EventV2Bridge.Service` to
|
||||
`Tier0Services`. (Note: `EventV2Bridge` is already imported in `server.ts`
|
||||
for the production routes but is not in any simulated tier.)
|
||||
6. Add `Reference.defaultLayer` to a tier that satisfies its deps (it's a leaf
|
||||
wrt the listed graph above) and `Reference.Service` to the corresponding
|
||||
tier union. Needed by `ToolRegistry`, `SessionPrompt`, `InstanceBootstrap`.
|
||||
|
||||
Production routes (`createProductionRoutes`) already include
|
||||
`RuntimeFlags.defaultLayer` and `EventV2Bridge.defaultLayer` in the flat
|
||||
`Layer.provide([...])` list — they were simply never carried over to the
|
||||
simulated chain after the rebase.
|
||||
|
||||
## See also
|
||||
|
||||
- `dependency-graph.html` — interactive visualization of the same data.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.4",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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,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,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,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",
|
||||
|
||||
@@ -5,15 +5,8 @@ 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 OPENCODE_SIMULATION = truthy("OPENCODE_SIMULATION")
|
||||
const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
|
||||
|
||||
export const Flag = {
|
||||
@@ -30,21 +23,16 @@ 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"),
|
||||
OPENCODE_SIMULATION,
|
||||
OPENCODE_SIMULATION_BACKEND: OPENCODE_SIMULATION || truthy("OPENCODE_SIMULATION_BACKEND"),
|
||||
|
||||
// Experimental
|
||||
OPENCODE_EXPERIMENTAL,
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_FILEWATCHER").pipe(
|
||||
Config.withDefault(false),
|
||||
),
|
||||
@@ -53,22 +41,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.
|
||||
|
||||
@@ -104,6 +104,7 @@ export const Provider = Schema.Struct({
|
||||
})
|
||||
|
||||
export type Provider = Schema.Schema.Type<typeof Provider>
|
||||
export const Catalog = Schema.Record(Schema.String, Provider)
|
||||
|
||||
export interface Interface {
|
||||
readonly get: () => Effect.Effect<Record<string, Provider>>
|
||||
|
||||
@@ -54,6 +54,29 @@ export interface Options {
|
||||
level?: Level
|
||||
}
|
||||
|
||||
export interface Entry {
|
||||
readonly time: string
|
||||
readonly level: Level
|
||||
readonly tags: Record<string, unknown>
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
const MAX_ENTRIES = 5_000
|
||||
const memory: Entry[] = []
|
||||
|
||||
function record(entry: Entry) {
|
||||
memory.push(entry)
|
||||
if (memory.length > MAX_ENTRIES) memory.splice(0, memory.length - MAX_ENTRIES)
|
||||
}
|
||||
|
||||
export function entries(): Entry[] {
|
||||
return memory.slice()
|
||||
}
|
||||
|
||||
export function clearEntries() {
|
||||
memory.length = 0
|
||||
}
|
||||
|
||||
let logpath = ""
|
||||
export function file() {
|
||||
return logpath
|
||||
@@ -139,24 +162,39 @@ export function create(tags?: Record<string, any>) {
|
||||
last = next.getTime()
|
||||
return [next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message].filter(Boolean).join(" ") + "\n"
|
||||
}
|
||||
|
||||
function capture(level: Level, message: any, extra?: Record<string, any>) {
|
||||
const text = message instanceof Error ? formatError(message) : message === undefined ? "" : String(message)
|
||||
record({
|
||||
time: new Date().toISOString(),
|
||||
level,
|
||||
tags: { ...tags, ...extra },
|
||||
message: text,
|
||||
})
|
||||
}
|
||||
|
||||
const result: Logger = {
|
||||
debug(message?: any, extra?: Record<string, any>) {
|
||||
if (shouldLog("DEBUG")) {
|
||||
capture("DEBUG", message, extra)
|
||||
write("DEBUG " + build(message, extra))
|
||||
}
|
||||
},
|
||||
info(message?: any, extra?: Record<string, any>) {
|
||||
if (shouldLog("INFO")) {
|
||||
capture("INFO", message, extra)
|
||||
write("INFO " + build(message, extra))
|
||||
}
|
||||
},
|
||||
error(message?: any, extra?: Record<string, any>) {
|
||||
if (shouldLog("ERROR")) {
|
||||
capture("ERROR", message, extra)
|
||||
write("ERROR " + build(message, extra))
|
||||
}
|
||||
},
|
||||
warn(message?: any, extra?: Record<string, any>) {
|
||||
if (shouldLog("WARN")) {
|
||||
capture("WARN", message, extra)
|
||||
write("WARN " + build(message, extra))
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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,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,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,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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
Simulation script runner.
|
||||
|
||||
Usage:
|
||||
bun run.ts <script.json> [options]
|
||||
|
||||
Options:
|
||||
--mcp <url> MCP endpoint (default http://127.0.0.1:43110/mcp)
|
||||
--chunk <n> Actions per step batch (default 3)
|
||||
--max-steps <n> Hard cap on step calls (default unlimited)
|
||||
--level <lvl> Stop level: DEBUG|INFO|WARN|ERROR (default ERROR)
|
||||
--message-includes <s> Only stop when message includes substring
|
||||
--service-includes <s> Only stop when tag.service includes substring
|
||||
--reset Reset sim state + restart TUI before load
|
||||
--no-reset Skip reset (default)
|
||||
--keep-going Don't stop on errors; continue to end
|
||||
--quiet Suppress per-batch progress
|
||||
--json Emit JSON summary at the end
|
||||
--check-every <n> Check logs every N batches (default 1)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
@@ -37,8 +37,8 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.4",
|
||||
"@octokit/webhooks-types": "7.6.1",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||
@@ -61,6 +61,7 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"just-bash": "3.0.1",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "catalog:",
|
||||
"vscode-languageserver-types": "3.17.5",
|
||||
|
||||
@@ -244,6 +244,7 @@ for (const item of targets) {
|
||||
{
|
||||
name,
|
||||
version: Script.version,
|
||||
preferUnplugged: true,
|
||||
os: [item.os],
|
||||
cpu: [item.arch],
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Property-Based TUI Testing Working Notes
|
||||
|
||||
## Mock LLM Provider
|
||||
|
||||
- `SessionPrompt` gets model metadata through `Provider.Service.getModel(...)`.
|
||||
- Actual generation is routed through `LLM.Service` / `streamText(...)`, so the first mock should return an AI SDK `LanguageModelV3` from the provider service.
|
||||
- The normal `Provider.layer` builds providers from config/models.dev/plugin state. For first pass, the simulated graph can replace `Provider.Service` with a smaller simulation provider service instead of trying to flow through provider config.
|
||||
- Control state should own an ordered LLM script queue. The provider/model should consume from that queue when the AI SDK calls the language model.
|
||||
- First version can support text-only output. Tool calls and stream chunk fidelity can come next.
|
||||
- Missing script should fail loudly with a typed simulation error, not silently return an empty assistant message.
|
||||
- Implemented `SimulationProvider.layer`, replacing `Provider.Service` in `createSimulatedRoutes`.
|
||||
- The provider exposes provider `simulation` and model `mock`.
|
||||
- `doGenerate` and `doStream` both consume one queued script through `Simulation.Service.nextLLM()`.
|
||||
- Current script support: `text`, `thinking` (treated as text for now), and `error`.
|
||||
- Snapshot currently records `llmQueued` and `llmConsumed`, not per-step details yet.
|
||||
|
||||
## OpenTUI Fake Renderer
|
||||
|
||||
- OpenTUI Solid exposes `testRender(...)` from `@opentui/solid`.
|
||||
- The lower-level core API is `createTestRenderer(...)` from `@opentui/core/testing`.
|
||||
- `createTestRenderer(...)` returns `renderer`, `mockInput`, `mockMouse`, `renderOnce`, `captureCharFrame`, `captureSpans`, and `resize`.
|
||||
- `captureCharFrame()` is the simple screen-buffer string API used heavily in OpenTUI snapshots.
|
||||
- `captureSpans()` returns structured lines/spans plus cursor position, which is a better starting point for visible element discovery than parsing raw characters.
|
||||
- `mockInput` supports interactions like `typeText`, `pressEnter`, and `pressArrow`.
|
||||
- Implemented `TuiSimulation.createSimulationRenderer(...)` beside `thread.ts`. It creates a test renderer and exposes `renderOnce`, `screen`, `spans`, and `destroy`.
|
||||
- `thread.ts` checks `OPENCODE_SIMULATION`, creates the fake renderer there, starts the normal worker/backend, and passes the renderer into `tui(...)`.
|
||||
- Backend route assembly checks `OPENCODE_SIMULATION_BACKEND`; `OPENCODE_SIMULATION` implies this flag.
|
||||
- `OPENCODE_SIMULATION_BACKEND=1` can run a real frontend against a simulated backend.
|
||||
- `tui(...)` now accepts an injected `CliRenderer`, test mode, and an `onReady` callback. Production still creates the real renderer.
|
||||
|
||||
## OpenTUI Action APIs
|
||||
|
||||
- Interactable discovery can walk `renderer.root.getChildren()` recursively.
|
||||
- `Renderable.focusable` and `Renderable.focused` are public and enough to discover focus targets.
|
||||
- `renderer.currentFocusedEditor` identifies active text input/edit-buffer targets for typing/submission.
|
||||
- `renderer.hitTest(x, y)` maps terminal coordinates through the hit grid to a renderable id.
|
||||
- Renderables have public geometry: `screenX`, `screenY`, `width`, `height`, and `num`.
|
||||
- Mouse listener metadata is stored internally on renderables; first pass checks `_mouseListener` / `_mouseListeners` at runtime to identify clickable targets. This is pragmatic but not a stable public API.
|
||||
- Test execution uses `mockInput.typeText`, `mockInput.pressEnter`, `mockInput.pressArrow`, and `mockMouse.click` from OpenTUI testing.
|
||||
- Implemented `SimulationActions` with `elements(...)`, `actions(...)`, and `execute(...)`.
|
||||
@@ -0,0 +1,388 @@
|
||||
# Property-Based TUI Testing
|
||||
|
||||
Status: first-pass implementation plan.
|
||||
|
||||
The goal is to drive the TUI against the real opencode app/backend while replacing external effects with deterministic simulation boundaries. The first pass should produce the smallest end-to-end system that can run the real app in a deterministic simulation environment and assert only that the app does not crash.
|
||||
|
||||
## Scope
|
||||
|
||||
Build these pieces first:
|
||||
|
||||
- Mock `AppFileSystem.Service` layer.
|
||||
- Mock `FetchHttpClient` layer with schema-generated responses through `toArbitrary()`.
|
||||
- Backend simulation control endpoint.
|
||||
- Mock LLM provider controlled by the endpoint.
|
||||
- OpenTUI fake renderer/screen-buffer/interactable-element access.
|
||||
- Basic action generator that drives the TUI forward.
|
||||
- Simulation-only embedded MCP server that lets agents observe and drive the TUI.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No semantic graph yet.
|
||||
- No advanced properties beyond no-crash.
|
||||
- No fake clock/timer control yet.
|
||||
- No shrinking yet.
|
||||
- No broad replacement of app services.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Load the normal app by default.
|
||||
- Keep overrides narrow and explicit.
|
||||
- The first core overrides are `AppFileSystem.Service` and `FetchHttpClient.layer`.
|
||||
- Do not replace `Provider.Service`, `SessionPrompt.Service`, `ToolRegistry.Service`, or the route tree wholesale unless we prove a narrow seam is impossible.
|
||||
- Use a backend control endpoint for LLM scripts and simulation state.
|
||||
- Force `OPENCODE_DB=:memory:` before any code imports `storage/db.ts`.
|
||||
- Run local simulation under `sandbox-exec` using the old branch setup as the starting point.
|
||||
- Use `sandbox-exec` as the safety boundary, not as the normal simulated I/O mechanism.
|
||||
- First built-in property: the app does not crash.
|
||||
- Only two simulation flags exist: `OPENCODE_SIMULATION` and `OPENCODE_SIMULATION_BACKEND`.
|
||||
- `OPENCODE_SIMULATION` starts the frontend-side simulation MCP server over stdio and implies backend simulation.
|
||||
- `OPENCODE_SIMULATION_BACKEND` without `OPENCODE_SIMULATION` starts the frontend-side simulation MCP server over loopback HTTP; in the TUI it shows the URL in the home screen.
|
||||
|
||||
## Target End-To-End Flow
|
||||
|
||||
1. Start opencode through the simulation runner.
|
||||
2. Runner sets `OPENCODE_DB=:memory:` before backend modules load.
|
||||
3. Runner installs the mock filesystem and mock HTTP client as narrow core overrides.
|
||||
4. Runner starts under `sandbox-exec` with host writes denied and external network denied.
|
||||
5. Runner mounts the TUI with a fake OpenTUI renderer instead of a real terminal.
|
||||
6. Test calls the simulation endpoint to seed filesystem/network/LLM state.
|
||||
7. Action generator performs one TUI action.
|
||||
8. Backend handles real app requests and uses endpoint-provided LLM scripts.
|
||||
9. Runner waits for quiescence.
|
||||
10. Built-in no-crash property checks TUI and backend errors.
|
||||
|
||||
## Mock AppFileSystem
|
||||
|
||||
Goal: backend-visible project/config/state files live in memory and never hit the host filesystem.
|
||||
|
||||
Implementation shape:
|
||||
|
||||
- Add `packages/opencode/src/testing/simulation/filesystem.ts`.
|
||||
- Implement an in-memory filesystem that can back `AppFileSystem.Service`.
|
||||
- Seed it from JSON fixtures supplied through the simulation endpoint or runner config.
|
||||
- Serialize it into replay traces.
|
||||
- Fail unsupported operations with typed simulation errors instead of silently falling back to host FS.
|
||||
- Enable the full simulation runner with `OPENCODE_SIMULATION`.
|
||||
- Enable only backend simulation with `OPENCODE_SIMULATION_BACKEND`.
|
||||
- `OPENCODE_SIMULATION` implies `OPENCODE_SIMULATION_BACKEND`.
|
||||
- Use a fixed virtual root, not `process.cwd()`, so host paths are denied by default.
|
||||
- Use the old branch's Bun preload/plugin redirection only for code paths that bypass `AppFileSystem.Service`.
|
||||
- Let `sandbox-exec` catch any remaining direct `fs`, `Bun.file`, or process-level filesystem access.
|
||||
|
||||
Required capabilities:
|
||||
|
||||
- Files and directories.
|
||||
- Text and binary content.
|
||||
- Deterministic `stat` metadata.
|
||||
- Deterministic path resolution for workspace root, cwd, home, config, state, and temp.
|
||||
- Reads and writes used by tools and config loading.
|
||||
- Directory listing and recursive traversal for glob/grep equivalents.
|
||||
- Snapshot/diff support or enough primitives for existing snapshot code to work.
|
||||
|
||||
Direct bypass candidates identified so far:
|
||||
|
||||
- `tool/read.ts` uses `createReadStream` directly for text line reads.
|
||||
- `patch/index.ts` uses `fs/promises` and `readFileSync` directly.
|
||||
- `storage/db.ts` uses sync `fs` APIs and must be protected by forcing `OPENCODE_DB=:memory:` before import.
|
||||
- `lsp/server.ts`, `util/filesystem.ts`, `file/watcher.ts`, and several CLI/TUI utilities use direct host filesystem APIs.
|
||||
- These should be redirected only when needed; otherwise `sandbox-exec` should catch leaks.
|
||||
|
||||
Todos:
|
||||
|
||||
- [x] Inspect `AppFileSystem.Service` interface and all methods used by backend code.
|
||||
- [x] List direct `@/util/filesystem`, `fs`, and `Bun.file` bypasses that matter in simulation mode.
|
||||
- [x] Define mock filesystem data model and fixture JSON format.
|
||||
- [x] Implement the `AppFileSystem.Service` layer.
|
||||
- [x] Add typed errors for unsupported operations and host-FS escapes.
|
||||
- [x] Add activation path from startup through `OPENCODE_SIMULATION` / `OPENCODE_SIMULATION_BACKEND`.
|
||||
- [x] Add a tiny fixture that includes `opencode.json`, a workspace root, and a few files.
|
||||
- [ ] Verify read/glob/grep/write/edit use the mock filesystem.
|
||||
- [ ] Verify sandbox denies host writes when a bypass is introduced.
|
||||
|
||||
## Mock FetchHttpClient
|
||||
|
||||
Goal: no backend code makes external network calls. Calls either return generated deterministic mock data or fail with a typed simulation error.
|
||||
|
||||
Implementation shape:
|
||||
|
||||
- Add `packages/opencode/src/testing/simulation/network.ts`.
|
||||
- Provide a narrow replacement for `FetchHttpClient.layer` / `HttpClient.HttpClient` in simulation startup.
|
||||
- Allow loopback only when needed for local app/TUI communication.
|
||||
- Deny all non-loopback network by default.
|
||||
- Add a response registry controlled by the simulation endpoint.
|
||||
- For registered schemas, generate deterministic data with `toArbitrary()` and the run seed.
|
||||
|
||||
Schema inference problem:
|
||||
|
||||
- Raw HTTP requests do not always carry the desired response schema.
|
||||
- First implementation should find where schema information exists for each network call path.
|
||||
- If the schema is not available from the raw `HttpClient` call, add a small registry keyed by request matcher and schema.
|
||||
- The endpoint can register `{ matcher, schema, seedOffset }`, and the mock client can call `toArbitrary(schema)` to generate the response.
|
||||
- Unknown requests should fail loudly instead of returning generic data.
|
||||
|
||||
Network call families found in the first inventory:
|
||||
|
||||
- Effect `HttpClient` with schemas close to the call site:
|
||||
- `account/account.ts`: opencode account/device/auth/org/user/config APIs. Response schemas are local (`TokenRefresh`, `Org`, `User`, `RemoteConfig`, `DeviceAuth`, `DeviceToken`).
|
||||
- `provider/models.ts`: `${OPENCODE_MODELS_URL || "https://models.dev"}/api.json`. Response schema is `Record<string, Provider>` but currently parsed after `res.text`; register this URL to the provider catalog schema.
|
||||
- `share/share-next.ts`: share create/sync/remove. Create response schema is `ShareSchema`; sync/remove can be empty/status-only.
|
||||
- `skill/discovery.ts`: skill index response schema is `Index`; skill file downloads are raw bytes/text.
|
||||
- `session/instruction.ts`: configured remote instruction URLs return text.
|
||||
- `tool/mcp-websearch.ts`, `tool/websearch.ts`, and `tool/codesearch.ts`: MCP-style tool calls to Exa/Parallel. Request schemas are local; response shape is MCP JSON-RPC/SSE with `McpResult`.
|
||||
- `tool/webfetch.ts`: arbitrary user URL returns raw text/html/image bytes, so it needs explicit registration by URL/content type rather than generic schema generation.
|
||||
- Effect `HttpClient` that should usually be disabled in first-pass simulation:
|
||||
- `installation/index.ts`: update/install metadata.
|
||||
- `file/ripgrep.ts`: ripgrep binary download.
|
||||
- UI and workspace proxy paths: allow only explicitly registered workspace URLs or loopback/local app traffic.
|
||||
- Raw `fetch` paths:
|
||||
- `config/config.ts`: well-known and remote config fetches. The schema is loose config JSON; register by configured URL if tests need this path.
|
||||
- `lsp/server.ts`: language-server release/download fetches. Disable by config in simulation or deny unless explicitly registered.
|
||||
- plugin auth/provider helpers (`plugin/codex.ts`, `plugin/github-copilot/*`, CLI commands): not part of first-pass TUI smoke unless explicitly exercised.
|
||||
- Provider SDK calls:
|
||||
- Most model traffic happens inside AI SDK provider packages, not directly through Effect `HttpClient`.
|
||||
- First pass should avoid mocking arbitrary provider SDK HTTP. Instead, register a local mock provider/model through the normal provider path and deny provider SDK fetches unless explicitly registered.
|
||||
- Remote MCP servers:
|
||||
- Config `mcp.<name>.url` is the registration point. When the app is given a remote MCP URL, the simulation network should register that URL as an MCP protocol endpoint for that named server.
|
||||
- The schema is not a single app schema; it is the MCP JSON-RPC/SSE protocol plus configured tool/resource/prompt definitions. The mock network should handle MCP protocol methods for registered MCP URLs and generate tool/list/call responses from simulation state.
|
||||
- The MCP SDK transport may bypass Effect `HttpClient`, so this likely needs either transport-level injection if the SDK supports custom fetch, or the old preload/global `fetch` redirection for registered MCP URLs only.
|
||||
|
||||
Registration model:
|
||||
|
||||
- `SimulationNetwork.Service` owns a registry keyed by method + URL matcher.
|
||||
- Registry entries should include a `source`/`kind` so failures explain why a URL was allowed or denied.
|
||||
- Rough implementation exists at `packages/opencode/src/testing/simulation/network.ts`.
|
||||
- Current rough registry supports exact URL, regex URL, or predicate matchers, optional method filters, parsed request bodies, static responses, dynamic response functions, and full handlers.
|
||||
- `SimulationNetworkRoutes` imports known schemas from the services that own HTTP call sites and registers schema-backed routes for hardcoded/configurable URL families.
|
||||
- Configurable/client-provided URLs should be registered through route-family helpers, e.g. `account(baseUrl)`, `models(baseUrl)`, `share(baseUrl)`, `skills(baseUrl)`, and `installation(registryUrl)`.
|
||||
- Some production schemas are too broad for `Schema.toArbitrary()` today, such as provider catalog fields containing arbitrary mutable JSON. For those cases, the first-pass route can use a narrower generated schema whose values still decode under the production schema.
|
||||
- Supported entry kinds for the first pass:
|
||||
- `jsonSchema`: generate JSON from an Effect `Schema` via `toArbitrary()`.
|
||||
- `text`: return deterministic text/html/markdown content for exact URLs.
|
||||
- `bytes`: return deterministic binary content for exact URLs.
|
||||
- `status`: return empty/status-only responses.
|
||||
- `handler`: inspect method, URL, headers, and parsed body to build a custom response.
|
||||
- `mcp`: handle JSON-RPC/SSE MCP protocol for a configured MCP server URL.
|
||||
- `loopback`: allow local app/TUI traffic only.
|
||||
- Prefer explicit registration at configuration/control boundaries over guessing from arbitrary URLs:
|
||||
- Account/server URL registrations come from account/auth setup.
|
||||
- MCP URL registrations come from `config.mcp`.
|
||||
- Web fetch/search URLs come from the simulation control endpoint or generated tool action.
|
||||
- Provider model responses come from the mock provider script registry, not generic provider SDK HTTP.
|
||||
- Unknown non-loopback URLs fail with a typed simulation network error.
|
||||
|
||||
Layering caveat:
|
||||
|
||||
- Several `defaultLayer`s still provide `FetchHttpClient.layer` internally (`Account`, `ModelsDev`, `ToolRegistry`, `ShareNext`, `SkillDiscovery`, `Instruction`, `Installation`, `Ripgrep`, `Workspace`). A top-level `HttpClient.HttpClient` mock does not necessarily affect those self-contained default layers.
|
||||
- First-pass startup wiring must either use non-default service layers and provide `SimulationNetwork.layer` once, or make these default layers explicitly mock-aware.
|
||||
- The same caveat already exists for `AppFileSystem.defaultLayer` in some default layers, so the final simulation startup needs an explicit “normal app with narrow mock boundaries” layer assembly rather than blindly using all default layers.
|
||||
|
||||
Todos:
|
||||
|
||||
- [x] Locate all backend uses of `HttpClient.HttpClient`, raw `fetch`, provider SDK fetches, webfetch/websearch/share/update paths.
|
||||
- [x] Classify first-pass network call families into schema-generated, text/bytes, MCP protocol, loopback, and denied.
|
||||
- [x] Decide where `toArbitrary()` lives or which package exports it.
|
||||
- [x] Define rough request matcher shape: exact URL, regex URL, or predicate.
|
||||
- [x] Add method-aware matching and parsed request body support.
|
||||
- [x] Define rough schema registration shape for generated responses.
|
||||
- [x] Add schema-backed route helpers for hardcoded and configurable URL families.
|
||||
- [ ] Define final schema registration shape for generated responses.
|
||||
- [ ] Define MCP URL registration from `config.mcp.<name>.url` to an MCP protocol handler.
|
||||
- [x] Implement rough seeded response generation with `Schema.toArbitrary()`.
|
||||
- [x] Add loopback allowlist handling.
|
||||
- [x] Add typed simulation error for unregistered non-loopback request.
|
||||
- [ ] Verify sandbox also blocks external network if mock client is bypassed.
|
||||
|
||||
## Control Endpoint And Mock LLM Provider
|
||||
|
||||
Goal: tests control backend behavior through an endpoint, and the model follows endpoint-provided scripts through the real prompt/session pipeline.
|
||||
|
||||
Implementation shape:
|
||||
|
||||
- Add simulation control state under `packages/opencode/src/testing/simulation/service.ts`.
|
||||
- Add HTTP routes under a simulation-gated path like `/experimental/simulation/*`.
|
||||
- Keep the route inaccessible unless simulation mode is explicitly enabled.
|
||||
- First pass uses a raw route wrapper at `packages/opencode/src/server/routes/instance/httpapi/simulation.ts` to avoid SDK regeneration while the API shape is still moving.
|
||||
- Current control service can reset state, seed filesystem files, register static network responses, and return a snapshot.
|
||||
- Register/configure a local mock provider/model through the normal provider path.
|
||||
- The simulated route graph replaces `Provider.Service` with `SimulationProvider.layer`.
|
||||
- The mock model reads queued scripts from simulation control state.
|
||||
- Current mock provider supports text/thinking/error actions for the first step only. Tool calls and multi-round step selection are still pending.
|
||||
- No JSON-in-prompt fallback.
|
||||
- Missing script means typed simulation error.
|
||||
|
||||
Initial endpoints:
|
||||
|
||||
- `POST /experimental/simulation/reset`
|
||||
- `POST /experimental/simulation/filesystem/seed`
|
||||
- `POST /experimental/simulation/network/register`
|
||||
- `POST /experimental/simulation/llm/enqueue`
|
||||
- `GET /experimental/simulation/snapshot`
|
||||
|
||||
Initial LLM script:
|
||||
|
||||
```ts
|
||||
type LLMScriptAction =
|
||||
| { type: "text"; content: string }
|
||||
| { type: "thinking"; content: string }
|
||||
| { type: "tool_call"; name: string; input: Record<string, unknown> }
|
||||
| { type: "list_tools" }
|
||||
| { type: "error"; message: string }
|
||||
|
||||
type LLMScript = {
|
||||
steps: LLMScriptAction[][]
|
||||
usage?: { inputTokens: number; outputTokens: number; totalTokens: number }
|
||||
finish?: "stop" | "tool-calls" | "error" | "length" | "unknown"
|
||||
}
|
||||
```
|
||||
|
||||
Keep the old useful rule: step `0` runs before tool results, step `N` runs after `N` tool-result rounds.
|
||||
|
||||
Todos:
|
||||
|
||||
- [x] Define simulation mode activation flag/env.
|
||||
- [x] Add simulation control state and reset semantics.
|
||||
- [x] Add gated simulation endpoints for reset, filesystem seed, network register, and snapshot.
|
||||
- [x] Decide raw route vs typed HttpApi route. Raw route for first pass; no SDK regeneration yet.
|
||||
- [x] Implement mock provider/model on the normal provider path.
|
||||
- [x] Make missing scripts fail with a typed simulation error.
|
||||
- [x] Record consumed script count in simulation snapshot.
|
||||
- [ ] Support tool-call script actions.
|
||||
- [ ] Support multi-step script selection after tool result rounds.
|
||||
- [ ] Verify `session.prompt_async` exercises real `SessionPrompt` and `SessionProcessor`.
|
||||
|
||||
## OpenTUI Fake Renderer And Interactable Elements
|
||||
|
||||
Goal: run the TUI without a real terminal, inspect the screen buffer, and discover/act on interactable elements.
|
||||
|
||||
Known starting points:
|
||||
|
||||
- Current TUI creates a real renderer in `packages/opencode/src/cli/cmd/tui/app.tsx` through `createCliRenderer(...)`.
|
||||
- Existing tests use `@opentui/solid` `testRender(...)`.
|
||||
- Existing tests use `@opentui/core/testing` `createTestRenderer(...)` for renderer snapshots.
|
||||
|
||||
Implementation shape:
|
||||
|
||||
- Add a renderer factory/testing hook to `tui(...)` so tests can pass a fake renderer.
|
||||
- Current first pass checks `OPENCODE_SIMULATION` in `cli/cmd/tui/thread.ts`, starts the normal worker/backend, and injects an OpenTUI test renderer into `tui(...)`.
|
||||
- `OPENCODE_SIMULATION_BACKEND` leaves the frontend real but makes backend route assembly use simulated services.
|
||||
- Fake renderer setup lives in `cli/cmd/tui/simulation.ts` and returns `renderOnce`, `screen`, and `spans` helpers for the thread-side simulation runner.
|
||||
- In simulation MCP modes, the TUI side starts an MCP server documented in `simulation-mcp-server.md`.
|
||||
- Initial action discovery lives in `packages/opencode/src/testing/simulation/actions.ts`.
|
||||
- OpenTUI exposes `renderer.root` for walking renderables, `Renderable.focusable`, `renderer.currentFocusedEditor`, `renderer.hitTest(...)`, and test `mockInput` / `mockMouse` APIs for execution.
|
||||
- Do not render to a real terminal in simulation mode.
|
||||
- Investigate OpenTUI APIs for walking the render tree and extracting focusable/clickable/editable elements.
|
||||
- Investigate OpenTUI APIs for reading the screen buffer from the fake renderer.
|
||||
- If OpenTUI does not expose enough semantic information, add a small TUI semantic registry later. Do not block first pass on a full registry.
|
||||
|
||||
Todos:
|
||||
|
||||
- [x] Inspect `@opentui/core/testing` `createTestRenderer` capabilities.
|
||||
- [x] Inspect `@opentui/solid` `testRender` capabilities.
|
||||
- [x] Determine how to get a screen buffer string/snapshot from the fake renderer.
|
||||
- [x] Determine first structured capture API for interactable discovery: `captureSpans()`.
|
||||
- [x] Add first pass renderable-based interactable discovery for focused editors, focusable elements, and mouse handlers.
|
||||
- [x] Add a minimal renderer factory override to `tui(...)` or app startup.
|
||||
- [ ] Expose prompt ref, route, sync state, keymap, and renderer to the simulation harness.
|
||||
- [ ] Verify TUI starts in fake renderer with no real terminal output.
|
||||
- [ ] Verify screen buffer can be captured after a render.
|
||||
|
||||
## Simulation MCP Server
|
||||
|
||||
Goal: let agents discover, inspect, and drive the simulated TUI through MCP without adding production remote-control behavior.
|
||||
|
||||
Design document:
|
||||
|
||||
- `packages/opencode/specs/simulation-mcp-server.md`
|
||||
|
||||
Implementation shape:
|
||||
|
||||
- Start in one of two modes: local stdio (`OPENCODE_SIMULATION=1`) or remote loopback HTTP (`OPENCODE_SIMULATION_BACKEND=1` without `OPENCODE_SIMULATION`).
|
||||
- Live in the TUI/frontend process so it can access the OpenTUI renderer.
|
||||
- Use stdio for the local agent-launched MCP mode.
|
||||
- Bind remote streamable HTTP MCP servers to `127.0.0.1` on an ephemeral port.
|
||||
- Print the URL to stdout for remote headless mode.
|
||||
- Show the URL at the bottom of the home screen for remote visible TUI mode.
|
||||
- Expose screen/spans/UI-state tools and resources.
|
||||
- Execute UI driving through `SimulationActions.execute(...)`, not a second action path.
|
||||
- Proxy filesystem/network/LLM/reset/snapshot operations to the backend simulation control endpoint.
|
||||
|
||||
Todos:
|
||||
|
||||
- [x] Add design doc and first-pass todo list.
|
||||
- [x] Implement TUI-side MCP server startup and shutdown.
|
||||
- [x] Add local stdio mode.
|
||||
- [x] Add remote loopback mode.
|
||||
- [x] Print remote headless URL to stdout.
|
||||
- [x] Show remote visible TUI URL on the home screen.
|
||||
- [x] Expose observation tools/resources.
|
||||
- [x] Expose generated action execution tools.
|
||||
- [x] Expose backend control proxy tools.
|
||||
- [x] Add a smoke test that connects with the MCP client and calls one observation tool.
|
||||
|
||||
## Basic Action Generator
|
||||
|
||||
Goal: drive the TUI forward with generated actions and assert only that the app does not crash.
|
||||
|
||||
Implementation shape:
|
||||
|
||||
- Add a seeded action generator under `packages/opencode/test/property` or `packages/opencode/src/testing/simulation` depending on whether it needs production imports.
|
||||
- Start with a tiny action set: submit prompt, key command, paste/type text, click/select visible interactable.
|
||||
- Prefer OpenTUI/fake-renderer interactions over direct component refs where possible.
|
||||
- Allow direct prompt ref use for the very first smoke path if OpenTUI interaction APIs are not ready.
|
||||
- After each action, wait for basic quiescence.
|
||||
- Built-in property is only `app.does-not-crash`.
|
||||
|
||||
Initial no-crash check:
|
||||
|
||||
```ts
|
||||
property({
|
||||
name: "app.does-not-crash",
|
||||
domains: ["tui", "backend"],
|
||||
async check(ctx) {
|
||||
ctx.expect(ctx.tui.errors).toEqual([])
|
||||
ctx.expect(ctx.backend.errors).toEqual([])
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Todos:
|
||||
|
||||
- [ ] Define `UIAction` union for the first pass.
|
||||
- [ ] Implement seeded RNG for action selection.
|
||||
- [ ] Generate ordinary prompt text and enqueue matching LLM scripts through the control endpoint.
|
||||
- [ ] Execute actions through fake renderer/OpenTUI APIs where available.
|
||||
- [ ] Add temporary prompt-ref execution path if needed for first smoke.
|
||||
- [ ] Wait for quiescence after each action.
|
||||
- [ ] Capture screen buffer and backend snapshot after each action.
|
||||
- [ ] Check only `app.does-not-crash`.
|
||||
- [ ] Persist a simple replay trace with seed, filesystem fixture, network registrations, LLM scripts, actions, and observations.
|
||||
|
||||
## First Milestone
|
||||
|
||||
The first milestone is one deterministic run that:
|
||||
|
||||
- Starts under `sandbox-exec`.
|
||||
- Uses `OPENCODE_DB=:memory:`.
|
||||
- Seeds the mock filesystem.
|
||||
- Mounts the TUI using a fake renderer.
|
||||
- Enqueues an LLM script through the control endpoint.
|
||||
- Submits an ordinary prompt through the TUI.
|
||||
- Receives a mocked model response through the real session pipeline.
|
||||
- Captures a screen buffer.
|
||||
- Starts the simulation MCP server in the selected transport mode.
|
||||
- Passes the no-crash property.
|
||||
|
||||
## First-Pass Todos
|
||||
|
||||
- [x] Mock filesystem layer works.
|
||||
- [ ] Mock FetchHttpClient works for registered schemas and fails unknown network. Rough static registry is implemented; schema generation remains.
|
||||
- [ ] Control endpoint can seed filesystem, register network schemas, enqueue LLM scripts, and snapshot state.
|
||||
- [ ] Mock provider/model consumes endpoint scripts through the real LLM path.
|
||||
- [ ] TUI runs with fake renderer.
|
||||
- [ ] Runner can inspect screen buffer.
|
||||
- [x] Simulation MCP server exposes screen/UI/actions/control to agents.
|
||||
- [ ] Runner can identify at least one interactable path to submit a prompt.
|
||||
- [ ] Basic action generator executes multiple deterministic steps.
|
||||
- [ ] No-crash property runs after each step.
|
||||
- [ ] Replay trace is written outside the sandbox.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Simulation MCP Server
|
||||
|
||||
Status: first-pass implementation plan.
|
||||
|
||||
The simulation MCP server gives agents a simulation-only control surface for the TUI. It lives in the TUI process because only the frontend has direct access to the OpenTUI renderer, captured screen buffer, focused editor, interactable elements, and input/mouse drivers.
|
||||
|
||||
## Goals
|
||||
|
||||
- Support local stdio mode for agents that launch opencode as an MCP server.
|
||||
- Support remote loopback HTTP mode for users that want to run the server themselves.
|
||||
- Expose current TUI state to agents: screen text, structured spans, interactable elements, focused editor, and generated actions.
|
||||
- Let agents drive the UI through the same `SimulationActions` execution path used by property tests.
|
||||
- Proxy backend simulation control operations through the existing `/experimental/simulation/*` endpoint.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not start this server outside simulation modes.
|
||||
- Do not add a general remote-control API to production TUI mode.
|
||||
- Do not replace the property-test action generator; MCP should call the same action generator.
|
||||
- Do not expose arbitrary host filesystem or network access.
|
||||
|
||||
## Location
|
||||
|
||||
- Server module: `packages/opencode/src/cli/cmd/tui/simulation-mcp.ts`.
|
||||
- Startup: `packages/opencode/src/cli/cmd/tui/thread.ts`, next to fake renderer creation.
|
||||
- Action/state source: `packages/opencode/src/testing/simulation/actions.ts`.
|
||||
- Backend mutation source: existing simulation control endpoints.
|
||||
|
||||
## Modes
|
||||
|
||||
Local stdio mode:
|
||||
|
||||
- Enabled by `OPENCODE_SIMULATION=1`.
|
||||
- Uses an OpenTUI test renderer and stdio MCP transport.
|
||||
- Prints nothing to stdout except MCP protocol messages.
|
||||
- Intended for agent MCP configs where the agent launches `opencode` as a local command.
|
||||
|
||||
Remote headless mode:
|
||||
|
||||
- Enabled by `OPENCODE_SIMULATION_BACKEND=1` when `OPENCODE_SIMULATION` is not set.
|
||||
- Uses an OpenTUI test renderer and streamable HTTP MCP transport.
|
||||
- Prints the running MCP URL to stdout once.
|
||||
- Intended for users or harnesses that want to start opencode and connect to the URL manually.
|
||||
|
||||
Remote visible TUI mode:
|
||||
|
||||
- Enabled by `OPENCODE_SIMULATION_BACKEND=1` when `OPENCODE_SIMULATION` is not set and the user is running the TUI.
|
||||
- Uses the normal visible TUI renderer and streamable HTTP MCP transport.
|
||||
- Does not print the URL to stdout because stdout belongs to the TUI.
|
||||
- Shows `Simulation mode MCP: <url>` at the bottom of the home screen.
|
||||
|
||||
## Transport
|
||||
|
||||
- Local stdio mode uses `StdioServerTransport`.
|
||||
- Remote modes use streamable HTTP over loopback.
|
||||
- Remote modes bind host `127.0.0.1`.
|
||||
- Remote port is ephemeral by default and configurable through `OPENCODE_SIMULATION_MCP_PORT`.
|
||||
|
||||
## Initial Tools
|
||||
|
||||
Observation:
|
||||
|
||||
- `simulation_screen_get`: return the current captured character frame.
|
||||
- `simulation_spans_get`: return OpenTUI captured spans.
|
||||
- `simulation_ui_state_get`: return elements, available generated actions, and focus state.
|
||||
|
||||
Driving:
|
||||
|
||||
- `simulation_action_execute`: execute one generated action and render once.
|
||||
- `simulation_action_sequence_execute`: execute a bounded sequence and return the final state.
|
||||
- `simulation_render_once`: force one render and return the screen/state.
|
||||
|
||||
Backend control proxy:
|
||||
|
||||
- `simulation_control_reset`
|
||||
- `simulation_control_filesystem_seed`
|
||||
- `simulation_control_network_register`
|
||||
- `simulation_control_llm_enqueue`
|
||||
- `simulation_control_snapshot`
|
||||
|
||||
## Initial Resources
|
||||
|
||||
- `simulation://screen`
|
||||
- `simulation://spans`
|
||||
- `simulation://ui-state`
|
||||
- `simulation://backend-snapshot`
|
||||
|
||||
## Initial Prompt
|
||||
|
||||
- `simulation-driver`: short instructions for agents to inspect state, choose available generated actions, drive the UI, then inspect again.
|
||||
|
||||
## Safety
|
||||
|
||||
- Guard startup with `OPENCODE_SIMULATION` or `OPENCODE_SIMULATION_BACKEND`.
|
||||
- Bind to loopback only.
|
||||
- Close the MCP server before destroying the renderer.
|
||||
- Keep backend state changes routed through the existing simulation control endpoint.
|
||||
|
||||
## Todos
|
||||
|
||||
- [x] Add this design document.
|
||||
- [x] Implement a first-pass TUI-side MCP server.
|
||||
- [x] Support local stdio mode.
|
||||
- [x] Support remote loopback mode.
|
||||
- [x] Print remote headless URL to stdout.
|
||||
- [x] Show remote background TUI URL on the home screen.
|
||||
- [x] Expose screen/spans/UI-state observation tools.
|
||||
- [x] Expose action execution tools using `SimulationActions.execute`.
|
||||
- [x] Expose backend control proxy tools.
|
||||
- [x] Add an automated smoke test that starts the MCP server and calls `tools/list` plus one observation tool.
|
||||
- [ ] Add richer action generation with generated text and bounded sequence traces.
|
||||
- [ ] Add trace capture for every MCP-driven action.
|
||||
- [ ] Add protocol-level docs for external agent authors.
|
||||
@@ -0,0 +1,103 @@
|
||||
# 02 Semantic Discovery
|
||||
|
||||
Status: speculative. Refine before implementation.
|
||||
|
||||
This phase starts after the first-pass action generator can drive the TUI and assert that the app does not crash.
|
||||
|
||||
## Goal
|
||||
|
||||
Build a semantic map of TUI states, available actions, backend requests, and backend state changes. This lets later runs focus on workflows instead of random screen poking.
|
||||
|
||||
## UI Semantics
|
||||
|
||||
We need a way to know what the runner can interact with on the current screen.
|
||||
|
||||
Preferred order:
|
||||
|
||||
- Use OpenTUI render tree/fake renderer APIs if they expose interactable elements.
|
||||
- Add a small TUI semantic registry only for missing metadata.
|
||||
- Avoid large per-component instrumentation at first.
|
||||
|
||||
Potential semantic element shape:
|
||||
|
||||
```ts
|
||||
type SemanticElement = {
|
||||
id: string
|
||||
role: "prompt" | "command" | "dialog" | "dialog-option" | "permission" | "question" | "message" | "route"
|
||||
label: string
|
||||
enabled: boolean
|
||||
visible: boolean
|
||||
state?: Record<string, unknown>
|
||||
bounds?: { x: number; y: number; width: number; height: number }
|
||||
actions: SemanticAction[]
|
||||
}
|
||||
```
|
||||
|
||||
## Backend Mapping
|
||||
|
||||
Every generated UI action should have an action ID. TUI requests should include simulation headers so backend observations can be correlated.
|
||||
|
||||
Headers:
|
||||
|
||||
- `x-opencode-simulation-run`
|
||||
- `x-opencode-simulation-action`
|
||||
- `x-opencode-simulation-step`
|
||||
|
||||
Record requests and events with enough metadata to answer:
|
||||
|
||||
- Which UI actions produced which backend requests?
|
||||
- Which backend domains changed?
|
||||
- Which TUI states became reachable?
|
||||
- Which generated path caused the crash if a crash happens?
|
||||
|
||||
Backend domains to consider later:
|
||||
|
||||
- `session`
|
||||
- `message`
|
||||
- `part`
|
||||
- `permission`
|
||||
- `question`
|
||||
- `todo`
|
||||
- `tool`
|
||||
- `mcp`
|
||||
- `filesystem`
|
||||
- `network`
|
||||
- `status`
|
||||
|
||||
## Graph Shape
|
||||
|
||||
The graph should abstract states rather than storing every concrete buffer.
|
||||
|
||||
```ts
|
||||
type SemanticState = {
|
||||
id: string
|
||||
route: string
|
||||
dialog?: string
|
||||
elementSignature: string
|
||||
backendSignature?: string
|
||||
}
|
||||
|
||||
type SemanticTransition = {
|
||||
id: string
|
||||
from: string
|
||||
to: string
|
||||
action: UIAction
|
||||
uiChanged: string[]
|
||||
backendRequests: BackendRequestRecord[]
|
||||
backendEvents: BackendEventRecord[]
|
||||
failures: SimulationFailure[]
|
||||
}
|
||||
```
|
||||
|
||||
## Todos
|
||||
|
||||
- [ ] Reassess OpenTUI APIs after first-pass fake renderer work.
|
||||
- [ ] Decide whether a TUI semantic registry is needed.
|
||||
- [ ] Add action IDs to generated actions.
|
||||
- [ ] Add action headers to TUI fetch wrapper.
|
||||
- [ ] Record backend request spans.
|
||||
- [ ] Record backend events and changed domains.
|
||||
- [ ] Define normalized UI state signatures.
|
||||
- [ ] Build first UI transition graph artifact.
|
||||
- [ ] Build first backend endpoint/domain graph artifact.
|
||||
- [ ] Use graph to bias action generation toward a selected workflow.
|
||||
@@ -0,0 +1,99 @@
|
||||
# 03 Properties And Replay
|
||||
|
||||
Status: speculative. Refine before implementation.
|
||||
|
||||
The first pass only checks that the app does not crash. Add more properties only after the basic runner and traces are stable.
|
||||
|
||||
## Property API
|
||||
|
||||
Properties should be ordinary TypeScript functions registered with the runner.
|
||||
|
||||
```ts
|
||||
type Property = {
|
||||
name: string
|
||||
domains: string[]
|
||||
check: (ctx: PropertyContext) => Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
The `domains` field lets the runner skip checks when unrelated state changed.
|
||||
|
||||
First pass property:
|
||||
|
||||
```ts
|
||||
property({
|
||||
name: "app.does-not-crash",
|
||||
domains: ["tui", "backend"],
|
||||
async check(ctx) {
|
||||
ctx.expect(ctx.tui.errors).toEqual([])
|
||||
ctx.expect(ctx.backend.errors).toEqual([])
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Later candidate properties:
|
||||
|
||||
- No non-loopback network call.
|
||||
- Session eventually becomes idle after prompt-like actions.
|
||||
- No pending tool call remains after idle.
|
||||
- Every TUI-visible session message has valid message/part schemas.
|
||||
- Permission/question overlays correspond to backend pending requests.
|
||||
- Replay trace can be parsed and rerun.
|
||||
- Text should not flicker across stable frames.
|
||||
- Dialog focus should remain valid.
|
||||
- Route state and visible route agree.
|
||||
- Backend DB invariants hold after endpoint groups.
|
||||
- Tool call lifecycle events are balanced.
|
||||
- No generated action sequence can strand a session in busy state.
|
||||
|
||||
## Failure Reports
|
||||
|
||||
Failure reports should be human-readable and point to the smallest useful context.
|
||||
|
||||
Report fields:
|
||||
|
||||
- Failed property name.
|
||||
- Seed and action index.
|
||||
- Minimal replay command.
|
||||
- Last N UI actions.
|
||||
- Backend requests/events caused by the failing action.
|
||||
- Visible TUI buffer before and after.
|
||||
- Relevant session/message/tool IDs.
|
||||
|
||||
## Replay Trace
|
||||
|
||||
Trace fields:
|
||||
|
||||
- Seed and run configuration.
|
||||
- Mock filesystem fixture and workspace/config path mapping.
|
||||
- Mock network schema registrations.
|
||||
- Simulation control calls.
|
||||
- LLM scripts consumed.
|
||||
- UI action sequence.
|
||||
- HTTP request records.
|
||||
- Backend events.
|
||||
- UI observations before/after each action.
|
||||
- Property checks and failure details.
|
||||
- Normalization version.
|
||||
|
||||
## Shrinking
|
||||
|
||||
Shrinking should come after exact replay is reliable.
|
||||
|
||||
Candidate shrink steps:
|
||||
|
||||
- Delete contiguous chunks of actions.
|
||||
- Reduce generated prompt text.
|
||||
- Reduce LLM scripts to fewer actions/steps.
|
||||
- Prefer semantic action shrinking over raw key shrinking.
|
||||
- Preserve control calls needed to reproduce backend state.
|
||||
|
||||
## Todos
|
||||
|
||||
- [ ] Keep first pass to `app.does-not-crash` only.
|
||||
- [ ] Define trace JSON schema after first runner exists.
|
||||
- [ ] Write replay command that reruns an exact trace.
|
||||
- [ ] Add readable failure report formatter.
|
||||
- [ ] Add network property after mock network is stable.
|
||||
- [ ] Add session/tool lifecycle properties after backend mapping is stable.
|
||||
- [ ] Add shrinker only after replay is deterministic.
|
||||
@@ -0,0 +1,52 @@
|
||||
# 04 DST Hardening
|
||||
|
||||
Status: speculative. Refine before implementation.
|
||||
|
||||
This phase moves from seeded generation plus replay toward deterministic simulation testing. Do not start here; first get the app running under the first-pass simulation environment.
|
||||
|
||||
## Stage 1: Record And Normalize
|
||||
|
||||
- Seed RNG for the runner.
|
||||
- Normalize timestamps and generated IDs in traces.
|
||||
- Record timer registrations and delayed events where easy.
|
||||
- Use quiescence waits instead of fake time.
|
||||
|
||||
## Stage 2: Deterministic Data Sources
|
||||
|
||||
- Add deterministic ID generation behind a narrow simulation mode if normalization becomes too noisy.
|
||||
- Replace `Math.random()` usage in simulation-facing paths with seeded RNG.
|
||||
- Keep provider, filesystem, and network deterministic through the first-pass simulation boundaries.
|
||||
|
||||
## Stage 3: Controlled Clock
|
||||
|
||||
- Move high-impact backend `Date.now()` call sites to Effect clock/time services where practical.
|
||||
- Add a simulation clock service.
|
||||
- Let the runner advance logical time.
|
||||
|
||||
## Stage 4: Controlled Timers And Event Loop
|
||||
|
||||
- Wrap TUI timer use through a scheduler service where practical.
|
||||
- Expose SDK event batching timers to the harness.
|
||||
- Let the runner advance timers as part of quiescence.
|
||||
|
||||
## Stage 5: Async Interleaving Exploration
|
||||
|
||||
- Randomize or systematically vary ordering of queued events, LLM chunks, tool completions, and sync flushes.
|
||||
- Replay exact interleavings from traces.
|
||||
|
||||
## Differential Runs
|
||||
|
||||
Later, reuse the useful idea from the old branch's differential runner:
|
||||
|
||||
- Run the same trace against two app versions or two configurations.
|
||||
- Normalize volatile fields.
|
||||
- Report semantic diffs instead of timestamp/ID noise.
|
||||
|
||||
## Todos
|
||||
|
||||
- [ ] Define which nondeterminism remains after first-pass replay.
|
||||
- [ ] Decide whether deterministic IDs are needed or trace normalization is enough.
|
||||
- [ ] Identify highest-impact `Date.now()` call sites.
|
||||
- [ ] Design a minimal simulation clock only if needed.
|
||||
- [ ] Design timer control only after fake renderer/action runner behavior is stable.
|
||||
- [ ] Add differential runner after trace replay is reliable.
|
||||
@@ -0,0 +1,69 @@
|
||||
# 05 Reference Notes
|
||||
|
||||
Status: reference material. Keep this short and update as implementation discovers new seams.
|
||||
|
||||
## Current TUI Map
|
||||
|
||||
- `packages/opencode/src/cli/cmd/tui/thread.ts`: starts the TUI worker and in-process transport.
|
||||
- `packages/opencode/src/cli/cmd/tui/app.tsx`: creates the OpenTUI renderer/keymap and renders the Solid app.
|
||||
- `packages/opencode/src/cli/cmd/tui/context/sdk.tsx`: SDK client, custom fetch, event source, event batching.
|
||||
- `packages/opencode/src/cli/cmd/tui/context/sync.tsx`: projects backend events into TUI state.
|
||||
- `packages/opencode/src/cli/cmd/tui/context/route.tsx`: route state.
|
||||
- `packages/opencode/src/cli/cmd/tui/context/prompt.tsx`: current prompt ref.
|
||||
- `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx`: prompt input and submit path.
|
||||
- `packages/opencode/src/cli/cmd/tui/keymap.tsx`: base keymap registration and `useBindings` exports.
|
||||
- `packages/opencode/src/cli/cmd/tui/plugin/api.tsx`: useful model for harness context exposure.
|
||||
|
||||
## Current Backend Map
|
||||
|
||||
- `packages/opencode/src/server/server.ts`: exposes `Server.Default().app.request(...)`.
|
||||
- `packages/opencode/src/server/routes/instance/httpapi/server.ts`: route tree and production layers.
|
||||
- `packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts`: prompt/prompt_async/session endpoints.
|
||||
- `packages/opencode/src/session/prompt.ts`: real prompt loop, tool resolution, LLM orchestration.
|
||||
- `packages/opencode/src/session/llm.ts`: provider language model seam and `streamText(...)` call.
|
||||
- `packages/opencode/src/provider/provider.ts`: normal provider discovery/loading path.
|
||||
- `packages/opencode/src/mcp/index.ts`: MCP network/process seam.
|
||||
- `packages/opencode/src/tool/registry.ts`: built-in and plugin tool registry.
|
||||
- `packages/opencode/src/storage/db.ts`: `OPENCODE_DB` and `:memory:` support.
|
||||
- `packages/opencode/src/id/id.ts`: timestamp/random ID generation.
|
||||
|
||||
## Prior Branch Notes
|
||||
|
||||
Branch: `jlongster/fuzz-backend`.
|
||||
|
||||
Useful ideas to reuse:
|
||||
|
||||
- Mock AI SDK provider emitted real language-model stream chunks.
|
||||
- Compact LLM script action format worked well.
|
||||
- Step selection by counting tool-result rounds worked well.
|
||||
- HTTP/SSE backend runner waited for `session.status` idle.
|
||||
- Tool discovery and schema-shaped fake input generation were useful.
|
||||
- TUI runner used internal prompt ref to submit scripted prompts.
|
||||
- Differential runner normalized volatile fields and compared runs.
|
||||
- SQLite was forced to `:memory:`.
|
||||
- `sandbox-exec` denied external network and host filesystem access.
|
||||
- Bun preload/plugin direction can catch imports that bypass service boundaries.
|
||||
|
||||
Things to avoid:
|
||||
|
||||
- No JSON-in-prompt protocol or fallback.
|
||||
- No unseeded `Math.random()` in generated actions.
|
||||
- No partial mock filesystem that silently falls back to host FS.
|
||||
- No broad replacement of app service graph when a narrow override works.
|
||||
|
||||
## Old Sandbox Setup
|
||||
|
||||
Starting files on prior branch:
|
||||
|
||||
- `packages/opencode/src/provider/sdk/mock/sandbox.sb`
|
||||
- `packages/opencode/src/provider/sdk/mock/run`
|
||||
|
||||
Important behavior:
|
||||
|
||||
- `sandbox-exec -f ... -D HOME=$HOME bun --preload ... src/index.ts serve`
|
||||
- `(allow default)` so the process can boot.
|
||||
- `(deny network*)` with localhost re-allowed.
|
||||
- `(deny file-write*)`.
|
||||
- Deny reads from `$HOME/.local` and `$HOME/.config`.
|
||||
|
||||
Adapt this setup into the new simulation runner layout rather than inventing a new sandbox policy first.
|
||||
@@ -0,0 +1,881 @@
|
||||
# Property-Based TUI And Backend Testing Plan
|
||||
|
||||
Status: rough architectural draft.
|
||||
|
||||
This document sketches an incremental path for property-based, deterministic-simulation-style testing of the opencode TUI against a real opencode backend, without hitting external network services.
|
||||
|
||||
## Goals
|
||||
|
||||
- Drive the TUI as the primary user surface.
|
||||
- Exercise the real backend request, session, message, tool, permission, and event pipelines.
|
||||
- Replace external effects with deterministic local services.
|
||||
- Record enough information to replay failures.
|
||||
- Build a semantic model of UI actions, backend requests, and state transitions over time.
|
||||
- Start with a small useful runner, then grow toward deterministic simulation testing.
|
||||
|
||||
## Non-Goals For The First Pass
|
||||
|
||||
- Full fake-clock replacement for every `setTimeout`, `Date.now`, and animation path.
|
||||
- Exhaustive exploration of every visual TUI state.
|
||||
- Web app testing.
|
||||
- Real external LLM/provider, MCP, webfetch, websearch, update, or share network calls.
|
||||
|
||||
## Current Code Map
|
||||
|
||||
### TUI
|
||||
|
||||
- TUI startup is centered in `packages/opencode/src/cli/cmd/tui/thread.ts` and `packages/opencode/src/cli/cmd/tui/app.tsx`.
|
||||
- `TuiThreadCommand` starts a worker, builds an in-process fetch/event transport when possible, and calls `tui(...)`.
|
||||
- `tui(...)` creates the OpenTUI `CliRenderer`, creates the keymap, and renders the Solid app.
|
||||
- `SDKProvider` in `context/sdk.tsx` owns SDK creation, custom fetch injection, event subscription, event batching, retry, and timers.
|
||||
- `SyncProvider` in `context/sync.tsx` projects backend events into TUI state: sessions, messages, parts, permissions, questions, todos, diffs, MCP, formatter, LSP, and VCS.
|
||||
- `RouteProvider` in `context/route.tsx` owns route state.
|
||||
- `PromptRefProvider` in `context/prompt.tsx` exposes the current prompt ref.
|
||||
- The main prompt is `component/prompt/index.tsx`. It exposes `set`, `reset`, and `submit` through `PromptRef`, and its submit path eventually calls SDK session APIs.
|
||||
- `keymap.tsx` centralizes base keymap registration and re-exports `useBindings`; app and prompt commands are registered through this layer.
|
||||
- `plugin/api.tsx` already centralizes access to renderer, route, keymap, state, SDK client, dialog, KV, and event APIs. This is a useful model for a test harness API.
|
||||
|
||||
### Backend
|
||||
|
||||
- `packages/opencode/src/server/server.ts` exposes `Server.Default().app.request(...)`, which is useful for in-process HTTP tests.
|
||||
- `packages/opencode/src/server/routes/instance/httpapi/server.ts` assembles all routes and provides production service layers.
|
||||
- `createRoutes(...)` currently provides concrete production layers inside the route builder, including `Provider.defaultLayer`, `MCP.defaultLayer`, `ToolRegistry.defaultLayer`, `AppFileSystem.defaultLayer`, and `FetchHttpClient.layer`.
|
||||
- `groups/session.ts` and `handlers/session.ts` define the important session HTTP surface: create, prompt, prompt_async, command, shell, abort, permission response, message reads, revert, and update paths.
|
||||
- `SessionPrompt.Service` in `session/prompt.ts` creates user messages, resolves prompt parts, resolves tools, loops over LLM/tool calls, and writes messages/parts.
|
||||
- `LLM.Service` in `session/llm.ts` is the main provider seam. It calls `Provider.Service.getLanguage(...)` and then `streamText(...)`.
|
||||
- `Provider.Service` in `provider/provider.ts` can dynamically load provider SDKs and may install packages or use network. Simulation should use the normal provider path with a local mock provider/model and sandbox/network guards, not wholesale service replacement.
|
||||
- `MCP.Service` in `mcp/index.ts` can open remote HTTP/SSE connections or local child processes. Simulation should keep normal app startup and disable/configure MCP by default; only add a narrow MCP control seam when a test needs MCP states.
|
||||
- `ToolRegistry.Service` in `tool/registry.ts` exposes built-in and plugin tools. Filesystem tools should run against the mock filesystem in simulation mode; process/network tools must be disabled or replaced.
|
||||
- `Database.Path` in `storage/db.ts` is controlled by `OPENCODE_DB` and supports `:memory:`. Tests already reset/close DB state.
|
||||
- `Identifier` in `id/id.ts`, many `Date.now()` calls, and some `Math.random()` use are determinism hazards.
|
||||
|
||||
### Previous `jlongster/fuzz-backend` Branch
|
||||
|
||||
Useful ideas:
|
||||
|
||||
- A mock AI SDK provider emitted real language-model stream chunks.
|
||||
- The old branch showed that a compact scripted action format works, but scripts must be supplied through simulation control APIs instead of user prompt text.
|
||||
- Actions included `text`, `thinking`, `tool_call`, `list_tools`, and `error`.
|
||||
- Step selection by counting tool-result rounds after the last user message was a good fit for model/tool loops.
|
||||
- The runner drove the backend through HTTP plus SSE, waited for `session.status` to become idle, and then inspected messages.
|
||||
- `/experimental/tool` discovery plus schema-based fake input generation was a useful generation seed.
|
||||
- The TUI runner used an internal component to select the mock model, set prompt text through `PromptRef`, submit, and wait for idle.
|
||||
- The differential runner normalized volatile fields and compared runs.
|
||||
- The runner forced SQLite to `:memory:` so each run started with a clean in-process database.
|
||||
- The runner used macOS `sandbox-exec` to deny external network and host filesystem access around the whole app process.
|
||||
- The branch included a mock filesystem direction; the concept is correct and should be made complete enough for backend tools and app services instead of relying on real workspace files.
|
||||
|
||||
Ideas to avoid or rework:
|
||||
|
||||
- Do not hardcode the mock provider into normal provider discovery.
|
||||
- Do not use unseeded `Math.random()`.
|
||||
- Do not make the user-visible prompt text carry hidden control instructions at all.
|
||||
- Do not implement a partial mock filesystem and assume all filesystem effects are covered; the backend mock filesystem must be a first-class simulation service with explicit unsupported-operation failures.
|
||||
|
||||
## Core Design Decision: Endpoint Control, Not Prompt Control
|
||||
|
||||
The primary harness should control backend behavior through a test-only simulation control endpoint or in-process control service. The TUI should then submit ordinary prompt text through the normal UI.
|
||||
|
||||
This is better than embedding control data in the prompt because:
|
||||
|
||||
- It keeps prompt contents realistic, so prompt UI behavior can be tested independently from backend scripting.
|
||||
- It keeps transcripts and message history understandable.
|
||||
- It works for non-prompt workflows like command palette actions, session summarization, permission flows, shell mode, model switching, and future MCP controls.
|
||||
- It lets the runner prepare backend state before the next UI action.
|
||||
- It gives us a natural place to force future backend state, such as MCP state, tool results, filesystem state, provider errors, and pending permission/question state.
|
||||
- It makes replay traces explicit: `control.enqueueLLM(...)`, then `ui.submitPrompt(...)`.
|
||||
|
||||
There should be no JSON-in-prompt fallback. If no endpoint-enqueued script matches a model request, the mock LLM should fail with a clear simulation error. This keeps user-visible prompt text realistic and makes replay traces explicit.
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
The system has five layers:
|
||||
|
||||
1. Simulation backend services.
|
||||
2. TUI driver and observation harness.
|
||||
3. Semantic UI and backend graph builder.
|
||||
4. Property runner, generator, replay, and shrinker.
|
||||
5. Later DST controls for clock, timers, schedulers, and async ordering.
|
||||
|
||||
The initial runner loop should look like this:
|
||||
|
||||
```text
|
||||
seed -> start isolated backend -> mount TUI -> observe state
|
||||
repeat N times:
|
||||
choose next UI action from current semantic state
|
||||
optionally enqueue backend script/control data
|
||||
execute the UI action
|
||||
wait for quiescence
|
||||
record UI/backend/network/event observations
|
||||
run relevant properties
|
||||
update semantic graph
|
||||
on failure:
|
||||
persist replay trace and human-readable report
|
||||
```
|
||||
|
||||
## Simulation Backend Services
|
||||
|
||||
### Production App With Narrow Overrides
|
||||
|
||||
The runner should load the normal app by default. Avoid building a separate test route tree or installing a broad graph of mock services. The goal is to run production wiring and only override the few core effect boundaries that must be deterministic.
|
||||
|
||||
The first required override is `AppFileSystem.Service`, so backend-visible files come from the in-memory mock filesystem. Other overrides should be added only when the app cannot be controlled through configuration, the simulation control endpoint, or the sandbox policy.
|
||||
|
||||
Possible narrow shape:
|
||||
|
||||
```ts
|
||||
// Conceptual API, not final names.
|
||||
export function createRoutes(input?: {
|
||||
cors?: CorsOptions
|
||||
overrides?: {
|
||||
appFileSystem?: Layer.Layer<AppFileSystem.Service>
|
||||
}
|
||||
}) {
|
||||
return productionRoutesWithProductionServices(input)
|
||||
}
|
||||
```
|
||||
|
||||
The important part is not the exact type. The important part is that simulation mode should not need to re-provide provider, MCP, tool registry, network, or most backend services. It should load the whole app and make the smallest viable changes, starting with the filesystem boundary.
|
||||
|
||||
### Simulation Control State
|
||||
|
||||
Add simulation-only control state that owns deterministic run state. This is not a replacement for app services; it is the small state store used by control endpoints and the mock provider.
|
||||
|
||||
Proposed source location:
|
||||
|
||||
- `packages/opencode/src/testing/simulation/service.ts`
|
||||
- `packages/opencode/src/testing/simulation/provider.ts`
|
||||
- `packages/opencode/src/testing/simulation/filesystem.ts`
|
||||
- `packages/opencode/src/testing/simulation/httpapi.ts`
|
||||
- `packages/opencode/src/testing/simulation/network.ts`
|
||||
- `packages/opencode/src/testing/simulation/runner.ts`
|
||||
|
||||
The service should be instance-scoped where possible and keyed by a `runID`.
|
||||
|
||||
Core responsibilities:
|
||||
|
||||
- Hold seeded RNG state.
|
||||
- Hold queued LLM scripts.
|
||||
- Hold mock filesystem state.
|
||||
- Record UI action IDs, backend request IDs, events, tool calls, and state changes.
|
||||
- Enforce network policy.
|
||||
- Provide snapshots for replay/failure reports.
|
||||
- Reset state between runs.
|
||||
|
||||
Conceptual control API:
|
||||
|
||||
```ts
|
||||
type SimulationControl = {
|
||||
reset(input: { runID: string; seed: string }): Effect.Effect<void>
|
||||
enqueueLLM(input: { runID: string; match?: LLMScriptMatch; script: LLMScript }): Effect.Effect<void>
|
||||
snapshot(input: { runID: string }): Effect.Effect<SimulationSnapshot>
|
||||
recordAction(input: UIActionRecord): Effect.Effect<void>
|
||||
recordRequest(input: BackendRequestRecord): Effect.Effect<void>
|
||||
recordEvent(input: BackendEventRecord): Effect.Effect<void>
|
||||
}
|
||||
```
|
||||
|
||||
### Control Endpoint
|
||||
|
||||
Add a gated endpoint under the instance HTTP API, probably `/experimental/simulation/*`.
|
||||
|
||||
Suggested endpoints:
|
||||
|
||||
- `POST /experimental/simulation/reset`
|
||||
- `POST /experimental/simulation/llm/enqueue`
|
||||
- `GET /experimental/simulation/snapshot`
|
||||
- `POST /experimental/simulation/action/start`
|
||||
- `POST /experimental/simulation/action/end`
|
||||
|
||||
Access should be impossible in normal production use unless explicit simulation mode is enabled. If the endpoint is added to the typed HttpApi surface, regenerate the JS SDK with `./packages/sdk/js/script/build.ts`. The runner can also call the endpoint with raw fetch to avoid making this a public user-facing API.
|
||||
|
||||
### Mock LLM Provider
|
||||
|
||||
The main LLM mock should be a real provider/model path, not a replacement for `SessionPrompt` or a wholesale replacement of `Provider.Service`.
|
||||
|
||||
Preferred seam:
|
||||
|
||||
- Register or configure a local simulation provider/model through the normal provider system.
|
||||
- Implement its language model with an AI SDK-compatible mock language model adapted to the current AI SDK version.
|
||||
- Let the existing `LLM.Service` call `streamText(...)`, process tools, and emit normal stream events.
|
||||
|
||||
This preserves more of the real backend path than replacing `LLM.Service` or `Provider.Service` directly.
|
||||
|
||||
The mock model should read the next script from `Simulation.Service` using request context:
|
||||
|
||||
- `runID`
|
||||
- `sessionID`
|
||||
- `messageID` or last user message ID
|
||||
- model/provider ID
|
||||
- tool round number
|
||||
|
||||
If no endpoint-enqueued script matches, the mock model should fail with a typed simulation error that includes the run ID, session ID, model, and tool round. Silent default responses and prompt parsing would hide missing runner setup.
|
||||
|
||||
Script action schema:
|
||||
|
||||
```ts
|
||||
type LLMScriptAction =
|
||||
| { type: "text"; content: string }
|
||||
| { type: "thinking"; content: string }
|
||||
| { type: "tool_call"; name: string; input: Record<string, unknown> }
|
||||
| { type: "list_tools" }
|
||||
| { type: "error"; message: string }
|
||||
|
||||
type LLMScript = {
|
||||
steps: LLMScriptAction[][]
|
||||
usage?: { inputTokens: number; outputTokens: number; totalTokens: number }
|
||||
finish?: "stop" | "tool-calls" | "error" | "length" | "unknown"
|
||||
}
|
||||
```
|
||||
|
||||
Keep the old rule that step `0` runs before tool results and step `N` runs after `N` tool-result rounds. The endpoint-backed service should also record which script step was consumed so replay reports are explicit.
|
||||
|
||||
### Database, Filesystem, Tools, MCP, And Network
|
||||
|
||||
Initial policy:
|
||||
|
||||
- Force `OPENCODE_DB=:memory:` for the simulation backend process. This should be a hard simulation-mode invariant, not a per-test preference.
|
||||
- Run the app/backend under macOS `sandbox-exec` by default for local simulation runs, following the old branch's technique: deny external network, deny host filesystem writes, and only allow the minimum paths needed to boot the process and communicate over loopback/in-process transports.
|
||||
- Add a first-class backend mock filesystem and provide it by overriding `AppFileSystem.Service` instead of relying on a real temp workspace for app-visible files.
|
||||
- Use the normal provider system with a local simulation provider/model; do not replace `Provider.Service` unless a later implementation proves a small seam is unavoidable.
|
||||
- Keep MCP on the normal app path and disable/configure it by default so it starts no network or child processes. Add narrow MCP controls later only for tests that explicitly target MCP states.
|
||||
- Use sandbox/network guards to reject non-loopback network. Only override `HttpClient.HttpClient` if a minimal core override is needed to make failures typed and observable.
|
||||
- Disable or fake webfetch, websearch, share, update, repo clone, and other external tools through normal config/tool policy where possible.
|
||||
- Run read/glob/grep/write/edit against the mock filesystem, not the host filesystem.
|
||||
- Treat bash/shell execution as opt-in and fake it by default, because real process execution bypasses the mock filesystem and sandbox policy is the last line of defense.
|
||||
|
||||
Later policy:
|
||||
|
||||
- Add deterministic fake child process and shell tools.
|
||||
- Add deterministic fake LSP/file-watcher events.
|
||||
|
||||
The mock filesystem should be authoritative for backend-visible project files. The host filesystem should only be used for runner artifacts, bundled source/config needed to start the app, and sandbox-allowed runtime plumbing. Any app path that escapes the mock filesystem should fail with a typed simulation error so missing coverage is obvious.
|
||||
|
||||
### In-Memory Database
|
||||
|
||||
Simulation mode should set the database to memory globally for the backend process:
|
||||
|
||||
```text
|
||||
OPENCODE_DB=:memory:
|
||||
```
|
||||
|
||||
This must happen before any import path evaluates `storage/db.ts`, because `Database.Path` is computed at module load. The simulation bootstrap should own process startup so this cannot be missed. Each run should start from an empty DB and seed any required sessions/state through public services or simulation controls.
|
||||
|
||||
### Sandbox Isolation
|
||||
|
||||
The local runner should reuse the old branch's `sandbox-exec` setup on macOS as the starting implementation. Specifically, adapt `jlongster/fuzz-backend:packages/opencode/src/provider/sdk/mock/sandbox.sb` and `jlongster/fuzz-backend:packages/opencode/src/provider/sdk/mock/run` into the new simulation runner layout.
|
||||
|
||||
The old setup did the right first-order thing:
|
||||
|
||||
- `sandbox-exec -f ... -D HOME=$HOME bun --preload ... src/index.ts serve`
|
||||
- Start from `(allow default)` so the process can boot.
|
||||
- Deny all network with `(deny network*)`.
|
||||
- Re-allow localhost network for local server/TUI communication.
|
||||
- Deny all filesystem writes with `(deny file-write*)`.
|
||||
- Deny reads from sensitive user config/state directories like `$HOME/.local` and `$HOME/.config`.
|
||||
|
||||
The sandbox is not the primary abstraction for deterministic behavior; it is the safety boundary that proves missed hooks cannot touch the host filesystem or external network.
|
||||
|
||||
Initial sandbox policy should stay close to the old branch:
|
||||
|
||||
- Deny outbound network except loopback when a real listener is used.
|
||||
- Deny host filesystem writes inside the sandbox. The parent runner can write trace artifacts outside the sandbox after collecting them over stdout, HTTP, or another explicit control channel.
|
||||
- Deny reads from user config/state locations unless explicitly mounted as test fixtures.
|
||||
- Fail fast when a denied operation happens so the trace records a simulation escape.
|
||||
|
||||
The mock filesystem and network guards should still exist inside the app. `sandbox-exec` catches leaks; it should not be the mechanism that normal simulated I/O depends on.
|
||||
|
||||
### Backend Mock Filesystem
|
||||
|
||||
The mock filesystem should be implemented as a real simulation service, not as test fixture files on disk.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Store files, directories, symlinks if needed, executable bits if needed, mtimes, and binary/text content in memory.
|
||||
- Provide deterministic path resolution for workspace root, current directory, home, config, state, and temp paths.
|
||||
- Expose operations needed by `AppFileSystem.Service`, read/write/edit tools, glob/grep/ripgrep equivalents, config reads, snapshot/diff, and prompt file attachment resolution.
|
||||
- Emit deterministic file change/snapshot events when writes happen.
|
||||
- Make unsupported operations fail explicitly with typed simulation errors.
|
||||
- Support seeding initial file trees from JSON fixtures and serializing filesystem state into replay traces.
|
||||
|
||||
Implementation should prefer a narrow `AppFileSystem.Service` override first. If file/ripgrep services or filesystem tools bypass that boundary, make the smallest targeted change to route them through the mock filesystem rather than replacing the whole tool registry. If some backend paths still import `@/util/filesystem` or direct host filesystem APIs, use the same Bun preload/plugin technique from the old branch to redirect those imports in simulation mode. The sandbox should then catch any remaining direct `fs`, `Bun.file`, or child-process access that was not routed through the mock filesystem.
|
||||
|
||||
### Backend Quiescence
|
||||
|
||||
The first useful quiescence definition should be pragmatic:
|
||||
|
||||
- No session has `session.status.type === "busy"`.
|
||||
- The TUI sync queue has flushed.
|
||||
- The runner has seen all events produced by the current action.
|
||||
- The renderer has completed at least one frame after the last event.
|
||||
- No pending simulation-controlled LLM stream or tool call remains.
|
||||
|
||||
This is not full DST yet. It is enough to avoid racing the next action against obvious async work.
|
||||
|
||||
## TUI Driver And Observation Harness
|
||||
|
||||
### Harness Injection
|
||||
|
||||
Do not add another hardcoded environment-runner component like the old `Mock` component. Instead, extend `tui(...)`/`App` with an optional test harness hook.
|
||||
|
||||
Conceptual shape:
|
||||
|
||||
```ts
|
||||
export function tui(input: {
|
||||
url: string
|
||||
args: Args
|
||||
config: TuiConfig.Resolved
|
||||
fetch?: typeof fetch
|
||||
events?: EventSource
|
||||
testing?: TuiHarness.Input
|
||||
})
|
||||
```
|
||||
|
||||
The `App` can mount a tiny `TuiHarnessProbe` only when `testing` is provided. The probe exposes the same kinds of context that `plugin/api.tsx` already gathers:
|
||||
|
||||
- route
|
||||
- keymap
|
||||
- prompt ref
|
||||
- sync state
|
||||
- SDK client
|
||||
- renderer
|
||||
- dialog state
|
||||
- KV state
|
||||
- event bus
|
||||
- local model/agent state
|
||||
|
||||
This gives tests a stable internal API without coupling to a specific visible component.
|
||||
|
||||
### Driver Modes
|
||||
|
||||
Start with two modes:
|
||||
|
||||
- Semantic in-process mode: uses context APIs, keymap commands, prompt refs, SDK fetch wrappers, and renderer snapshots. This is the main property runner.
|
||||
- Terminal/PTY mode: later, spawn the real binary in a PTY, inject bytes, and read terminal snapshots. This catches lower-level terminal regressions but is slower.
|
||||
|
||||
The semantic mode should still exercise OpenTUI rendering, Solid state, keymap registration, SDK calls, backend routes, SSE/events, and prompt submit flows.
|
||||
|
||||
### Action Types
|
||||
|
||||
Initial action types:
|
||||
|
||||
```ts
|
||||
type UIAction =
|
||||
| { type: "command"; command: string }
|
||||
| { type: "prompt.set"; text: string }
|
||||
| { type: "prompt.submit"; text?: string; llm?: LLMScript }
|
||||
| { type: "key"; key: string; modifiers?: string[] }
|
||||
| { type: "paste"; text: string }
|
||||
| { type: "click"; elementID: string }
|
||||
| { type: "wait"; condition: "idle" | "frame"; timeoutMs: number }
|
||||
```
|
||||
|
||||
The runner should prefer semantic commands first. Raw key and mouse actions are useful, but command-level actions are easier to shrink and replay.
|
||||
|
||||
### Observation Types
|
||||
|
||||
Every action should record before/after observations:
|
||||
|
||||
```ts
|
||||
type UIObservation = {
|
||||
route: unknown
|
||||
dialogDepth: number
|
||||
focusedElement?: string
|
||||
semanticElements: SemanticElement[]
|
||||
bufferHash?: string
|
||||
visibleText?: string
|
||||
syncSummary: SyncSummary
|
||||
errors: SimulationError[]
|
||||
}
|
||||
```
|
||||
|
||||
Initial `visibleText` can come from renderer/test-render snapshots where available. Later PTY mode should capture the terminal buffer directly.
|
||||
|
||||
### TUI State Changers To Track
|
||||
|
||||
Frontend state can change because of:
|
||||
|
||||
- Keyboard events.
|
||||
- Mouse events.
|
||||
- Paste and IME submit deferrals.
|
||||
- Terminal resize and theme detection.
|
||||
- SDK HTTP responses.
|
||||
- SDK event stream messages.
|
||||
- Timers used for batching, focus, prompt submit, animations, retry, and placeholders.
|
||||
- Local KV, prompt history, prompt stash, model recents/favorites.
|
||||
- Plugin registration, routes, slots, commands, events, and toasts.
|
||||
- Clipboard/selection flows.
|
||||
- Process signals and terminal suspend/resume.
|
||||
|
||||
The first runner does not need full control over all of these. It should record them when they occur and gradually move high-impact sources under simulation control.
|
||||
|
||||
## Semantic UI Graph
|
||||
|
||||
### Semantic Registry
|
||||
|
||||
Add a TUI semantic registry that components can use to announce interactive elements and available actions.
|
||||
|
||||
Conceptual element shape:
|
||||
|
||||
```ts
|
||||
type SemanticElement = {
|
||||
id: string
|
||||
role: "prompt" | "command" | "dialog" | "dialog-option" | "permission" | "question" | "message" | "route"
|
||||
label: string
|
||||
enabled: boolean
|
||||
visible: boolean
|
||||
state?: Record<string, unknown>
|
||||
bounds?: { x: number; y: number; width: number; height: number }
|
||||
actions: SemanticAction[]
|
||||
}
|
||||
```
|
||||
|
||||
First components to instrument:
|
||||
|
||||
- `Prompt` for text input, submit, shell mode, slash commands, file/agent attachments.
|
||||
- App commands registered in `app.tsx`.
|
||||
- Prompt commands registered in `component/prompt/index.tsx`.
|
||||
- `DialogSelect` for option movement/filter/select.
|
||||
- Permission and question overlays.
|
||||
- Route state in `RouteProvider`.
|
||||
- Session message parts, especially tool and error parts.
|
||||
|
||||
This should be additive metadata. It should not change rendering behavior.
|
||||
|
||||
### Graph Shape
|
||||
|
||||
The graph should abstract states instead of storing every concrete UI snapshot.
|
||||
|
||||
```ts
|
||||
type SemanticState = {
|
||||
id: string
|
||||
route: string
|
||||
dialog?: string
|
||||
elementSignature: string
|
||||
backendSignature?: string
|
||||
}
|
||||
|
||||
type SemanticTransition = {
|
||||
id: string
|
||||
from: string
|
||||
to: string
|
||||
action: UIAction
|
||||
uiChanged: string[]
|
||||
backendRequests: BackendRequestRecord[]
|
||||
backendEvents: BackendEventRecord[]
|
||||
coverage: string[]
|
||||
failures: SimulationFailure[]
|
||||
}
|
||||
```
|
||||
|
||||
State hashing should initially normalize volatile IDs/timestamps. As deterministic IDs/clocks land, less normalization will be needed.
|
||||
|
||||
### Discovery Pass
|
||||
|
||||
The discovery runner randomly chooses from currently available semantic actions, executes them, observes transitions, and writes a graph artifact.
|
||||
|
||||
Suggested output:
|
||||
|
||||
- `.opencode/simulation/ui-graph.json`
|
||||
- `.opencode/simulation/backend-graph.json`
|
||||
- `.opencode/simulation/runs/<runID>.jsonl`
|
||||
|
||||
The graph is not expected to be perfect. It should answer practical questions:
|
||||
|
||||
- What actions are available from each abstract UI state?
|
||||
- Which actions produce backend requests?
|
||||
- Which actions open dialogs, create sessions, request permissions, create tool parts, or show errors?
|
||||
- Which action sequences reach the prompt, session, permission, question, model selection, MCP, and session list states?
|
||||
|
||||
### Directed Runner
|
||||
|
||||
After discovery, the directed runner should use the graph to bias generation toward requested targets.
|
||||
|
||||
Examples:
|
||||
|
||||
- "Focus prompt submit with tool calls."
|
||||
- "Exercise permission approve/reject flows."
|
||||
- "Exercise session list and route changes."
|
||||
- "Exercise backend prompt_async and SessionPrompt loop states."
|
||||
|
||||
The directed runner can plan a route through the graph to a target state, then run generated variants from there.
|
||||
|
||||
## Mapping UI Actions To Backend Requests
|
||||
|
||||
Every generated action should have an `actionID`.
|
||||
|
||||
The TUI fetch wrapper should add headers:
|
||||
|
||||
- `x-opencode-simulation-run`
|
||||
- `x-opencode-simulation-action`
|
||||
- `x-opencode-simulation-step`
|
||||
|
||||
The backend should record request spans:
|
||||
|
||||
```ts
|
||||
type BackendRequestRecord = {
|
||||
runID: string
|
||||
actionID?: string
|
||||
requestID: string
|
||||
method: string
|
||||
path: string
|
||||
endpoint?: string
|
||||
status: number
|
||||
startedAt: number
|
||||
endedAt: number
|
||||
}
|
||||
```
|
||||
|
||||
Async work needs explicit correlation. For example, `prompt_async` returns before the session run finishes. The handler should attach the current `actionID` to the created user message/session run in `Simulation.Service`, so later LLM/tool/session events can be attributed to the same UI action.
|
||||
|
||||
Backend events should also be recorded:
|
||||
|
||||
```ts
|
||||
type BackendEventRecord = {
|
||||
runID: string
|
||||
actionID?: string
|
||||
eventID: string
|
||||
type: string
|
||||
sessionID?: string
|
||||
messageID?: string
|
||||
domains: string[]
|
||||
}
|
||||
```
|
||||
|
||||
This gives the graph the important edge information: UI action -> HTTP request -> backend state/event changes -> TUI sync changes.
|
||||
|
||||
## Backend Semantic Analysis
|
||||
|
||||
Backend semantic analysis should start from cheap instrumentation:
|
||||
|
||||
- HTTP endpoint entry/exit.
|
||||
- Bus/SyncEvent publications.
|
||||
- Session status changes.
|
||||
- Message and part writes.
|
||||
- Permission and question asks/replies.
|
||||
- Tool start/finish/error.
|
||||
- LLM stream start/finish/error.
|
||||
|
||||
The first backend state domains:
|
||||
|
||||
- `session`
|
||||
- `message`
|
||||
- `part`
|
||||
- `permission`
|
||||
- `question`
|
||||
- `todo`
|
||||
- `tool`
|
||||
- `mcp`
|
||||
- `filesystem`
|
||||
- `network`
|
||||
- `status`
|
||||
|
||||
Later, add DB snapshots or table-level hashes for deeper invariants. Do not read and diff the whole database after every action until we know it is needed.
|
||||
|
||||
## Property API
|
||||
|
||||
Properties should be ordinary TypeScript functions registered with the runner.
|
||||
|
||||
Conceptual API:
|
||||
|
||||
```ts
|
||||
type Property = {
|
||||
name: string
|
||||
domains: string[]
|
||||
check: (ctx: PropertyContext) => Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
`domains` lets the runner skip checks when unrelated state changed.
|
||||
|
||||
Example properties:
|
||||
|
||||
```ts
|
||||
property({
|
||||
name: "app.does-not-crash",
|
||||
domains: ["tui", "backend"],
|
||||
async check(ctx) {
|
||||
ctx.expect(ctx.tui.errors).toEqual([])
|
||||
ctx.expect(ctx.backend.errors).toEqual([])
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Built-in properties for pass one:
|
||||
|
||||
- The app does not crash. This includes uncaught TUI render errors and unhandled backend failures caused by the generated action.
|
||||
|
||||
Later properties:
|
||||
|
||||
- No non-loopback network call.
|
||||
- Session eventually becomes idle after prompt-like actions.
|
||||
- No pending tool call remains after idle.
|
||||
- Every TUI-visible session message has valid message/part schemas.
|
||||
- Permission/question overlays correspond to backend pending requests.
|
||||
- Replay trace can be parsed and rerun.
|
||||
- Text should not flicker across stable frames.
|
||||
- Dialog focus should remain valid.
|
||||
- Route state and visible route agree.
|
||||
- Backend DB invariants hold after every endpoint group.
|
||||
- Tool call lifecycle events are balanced.
|
||||
- No generated action sequence can strand a session in busy state.
|
||||
|
||||
## Failure Reports And Replay
|
||||
|
||||
On failure, persist a trace and a concise report.
|
||||
|
||||
Trace should include:
|
||||
|
||||
- Seed and run configuration.
|
||||
- Initial mock filesystem fixture and workspace/config path mapping.
|
||||
- Simulation control calls.
|
||||
- UI action sequence.
|
||||
- LLM scripts consumed.
|
||||
- HTTP request records.
|
||||
- Backend events.
|
||||
- UI observations before/after each action.
|
||||
- Property checks and failure details.
|
||||
- Normalization version.
|
||||
|
||||
Human report should include:
|
||||
|
||||
- Failed property name.
|
||||
- Seed and action index.
|
||||
- Minimal replay command.
|
||||
- The last N UI actions.
|
||||
- The backend requests/events caused by the failing action.
|
||||
- Visible TUI text/buffer before and after.
|
||||
- Any session/message/tool IDs relevant to the failure.
|
||||
|
||||
Initial replay can simply rerun the exact trace. Shrinking can come later.
|
||||
|
||||
Shrinking plan:
|
||||
|
||||
- Delete contiguous chunks of actions.
|
||||
- Reduce generated prompt text.
|
||||
- Reduce LLM scripts to fewer actions/steps.
|
||||
- Prefer semantic action shrinking over raw key shrinking.
|
||||
- Preserve explicit control calls needed to reproduce backend state.
|
||||
|
||||
## DST Roadmap
|
||||
|
||||
Full deterministic simulation testing requires more than seeded random actions. It requires control over time and async scheduling. Build this gradually.
|
||||
|
||||
### Stage 1: Record And Normalize
|
||||
|
||||
- Seed RNG for the runner.
|
||||
- Normalize timestamps and generated IDs in traces.
|
||||
- Record timer registrations and delayed events where easy.
|
||||
- Use quiescence waits instead of fake time.
|
||||
|
||||
### Stage 2: Deterministic Data Sources
|
||||
|
||||
- Add deterministic ID generation behind an injectable service or simulation mode.
|
||||
- Replace `Math.random()` usage in TUI placeholders/tests with seeded RNG in simulation mode.
|
||||
- Replace provider, MCP, network, and unsafe tools with deterministic services.
|
||||
|
||||
### Stage 3: Controlled Clock
|
||||
|
||||
- Move high-impact backend `Date.now()` call sites to Effect clock/time services.
|
||||
- Add a simulation clock service.
|
||||
- Let the runner advance logical time.
|
||||
|
||||
### Stage 4: Controlled Timers And Event Loop
|
||||
|
||||
- Wrap TUI timer use through a scheduler service where practical.
|
||||
- Expose SDK event batching timers to the harness.
|
||||
- Let the runner advance timers as part of quiescence.
|
||||
|
||||
### Stage 5: Async Interleaving Exploration
|
||||
|
||||
- Randomize or systematically vary ordering of queued events, LLM chunks, tool completions, and sync flushes.
|
||||
- Replay exact interleavings from traces.
|
||||
|
||||
## Implementation Passes
|
||||
|
||||
### Pass 1: Backend-Only Deterministic Prompt Runner
|
||||
|
||||
Deliverables:
|
||||
|
||||
- Local mock LLM provider/model registered through the normal provider path.
|
||||
- Simulation control state and raw or typed control endpoint.
|
||||
- Sandboxed backend runner that loads the normal app and applies only narrow core overrides, starting with `AppFileSystem.Service`.
|
||||
- Process bootstrap that forces `OPENCODE_DB=:memory:` before backend modules load.
|
||||
- Initial backend mock filesystem service with seeded fixture support.
|
||||
- macOS `sandbox-exec` runner wrapper that denies external network and host filesystem access.
|
||||
- Seeded generation of LLM scripts based on available tools.
|
||||
- Replay trace for backend-only prompt runs.
|
||||
|
||||
Scope:
|
||||
|
||||
- Create session.
|
||||
- Seed mock filesystem contents.
|
||||
- Enqueue LLM script.
|
||||
- Call `prompt_async`.
|
||||
- Wait for idle over events.
|
||||
- Assert basic backend properties.
|
||||
|
||||
Validation:
|
||||
|
||||
- Run 10 to 100 generated backend prompt cases without external network.
|
||||
- Prove filesystem reads/writes hit the mock filesystem and not the host filesystem.
|
||||
- Prove tool-call, text, reasoning, and error scripts hit the real `SessionPrompt` and `SessionProcessor` path.
|
||||
|
||||
### Pass 2: TUI Prompt Smoke Runner
|
||||
|
||||
Deliverables:
|
||||
|
||||
- Optional `testing` hook in `tui(...)`/`App`.
|
||||
- In-process TUI harness exposing prompt ref, route, sync, keymap, SDK, and renderer.
|
||||
- Fetch/event wrappers that add simulation action headers and record requests/events.
|
||||
- A runner action that enqueues LLM script, sets prompt text through TUI, submits, waits for idle, and checks no crash.
|
||||
|
||||
Scope:
|
||||
|
||||
- Prompt input and submit only.
|
||||
- Normal text and one tool-call script.
|
||||
- Real backend, fake external services.
|
||||
|
||||
Validation:
|
||||
|
||||
- Run TUI -> prompt -> backend -> LLM script -> tool/result -> TUI message display.
|
||||
- Persist and replay a trace.
|
||||
|
||||
### Pass 3: Semantic UI Registry
|
||||
|
||||
Deliverables:
|
||||
|
||||
- `TuiSemanticProvider` and registry API.
|
||||
- Instrument prompt, app commands, prompt commands, route state, dialog select, permission, and question components.
|
||||
- Snapshot current semantic elements from the harness.
|
||||
- Random semantic action generator.
|
||||
|
||||
Scope:
|
||||
|
||||
- Commands, prompt text/submit, dialog option select, permission approve/reject, question answer/reject.
|
||||
|
||||
Validation:
|
||||
|
||||
- Generate random semantic actions for a fixed number of steps.
|
||||
- Build a small UI transition graph.
|
||||
- Replay any generated sequence.
|
||||
|
||||
### Pass 4: Directed Property Runner
|
||||
|
||||
Deliverables:
|
||||
|
||||
- Property registration API.
|
||||
- Domain-based property filtering.
|
||||
- Built-in no-crash/no-network/session-idle/tool-lifecycle properties.
|
||||
- Directed generation targets based on semantic graph.
|
||||
|
||||
Scope:
|
||||
|
||||
- User asks for a focus area and iteration/depth count.
|
||||
- Runner biases actions toward graph paths related to that area.
|
||||
|
||||
Validation:
|
||||
|
||||
- `prompt submit with tool calls` target produces many prompt/tool/session variants.
|
||||
- `permission flows` target reaches permission UI and exercises approve/reject.
|
||||
|
||||
### Pass 5: Backend Graph And Endpoint Mapping
|
||||
|
||||
Deliverables:
|
||||
|
||||
- Request and event correlation by action ID.
|
||||
- Backend domain change records.
|
||||
- Endpoint/action graph export.
|
||||
- Basic backend state signatures.
|
||||
|
||||
Scope:
|
||||
|
||||
- Session, message, part, permission, question, todo, tool, and status domains.
|
||||
|
||||
Validation:
|
||||
|
||||
- Given a UI action, report which backend endpoints and state domains changed.
|
||||
- Given a backend endpoint/domain, report UI actions that reached it.
|
||||
|
||||
### Pass 6: Determinism Hardening
|
||||
|
||||
Deliverables:
|
||||
|
||||
- Seeded RNG everywhere in the runner.
|
||||
- Deterministic ID/time mode for high-impact backend paths.
|
||||
- Timer registration recording.
|
||||
- More complete network/process guards.
|
||||
- Complete backend mock filesystem coverage for configured filesystem tools and app services.
|
||||
|
||||
Scope:
|
||||
|
||||
- Reduce trace normalization.
|
||||
- Make failures replay reliably across machines.
|
||||
|
||||
Validation:
|
||||
|
||||
- Same seed and trace produce same observations modulo approved volatile fields.
|
||||
|
||||
### Pass 7: Shrinking And Differential Runs
|
||||
|
||||
Deliverables:
|
||||
|
||||
- Action sequence shrinker.
|
||||
- Prompt/script shrinker.
|
||||
- Dual-run differential runner similar in spirit to the old branch.
|
||||
- Stable normalization rules for diff output.
|
||||
|
||||
Scope:
|
||||
|
||||
- Compare current branch against baseline or two configurations.
|
||||
|
||||
Validation:
|
||||
|
||||
- Induced failure shrinks to a short reproducible action trace.
|
||||
- Differential runner reports meaningful semantic diffs, not timestamp/ID noise.
|
||||
|
||||
## Suggested Initial File Layout
|
||||
|
||||
```text
|
||||
packages/opencode/src/testing/simulation/service.ts
|
||||
packages/opencode/src/testing/simulation/provider.ts
|
||||
packages/opencode/src/testing/simulation/filesystem.ts
|
||||
packages/opencode/src/testing/simulation/httpapi.ts
|
||||
packages/opencode/src/testing/simulation/network.ts
|
||||
packages/opencode/src/testing/simulation/mcp.ts
|
||||
packages/opencode/src/testing/simulation/tool-registry.ts
|
||||
packages/opencode/src/testing/simulation/sandbox.sb
|
||||
packages/opencode/src/testing/simulation/run.ts
|
||||
packages/opencode/src/cli/cmd/tui/testing/harness.tsx
|
||||
packages/opencode/src/cli/cmd/tui/testing/semantic.tsx
|
||||
packages/opencode/test/property/backend-runner.test.ts
|
||||
packages/opencode/test/property/tui-runner.test.ts
|
||||
packages/opencode/test/property/properties.ts
|
||||
packages/opencode/test/property/generator.ts
|
||||
```
|
||||
|
||||
If we want the harness code completely out of production bundles, keep more of it under `test/property`. The server route, TUI optional hook, and any simulation-gated services that the app imports need to live under `src`.
|
||||
|
||||
## Open Questions To Resolve During Implementation
|
||||
|
||||
- Should the simulation endpoint be a typed HttpApi route that regenerates SDK, or an internal raw route used only by the runner?
|
||||
- Should the first mock model target the current AI SDK provider interface directly, or temporarily fake `LLM.Service` while the provider mock is adapted?
|
||||
- How much renderer tree metadata does OpenTUI expose for stable bounds and visible text snapshots?
|
||||
- Which tools should be enabled by default in generation: read/glob/grep/todo only, or write/edit against the mock filesystem too?
|
||||
- Where should trace artifacts live by default so they are easy to inspect but not accidentally committed?
|
||||
|
||||
## Recommended Starting Point
|
||||
|
||||
Start with Pass 1 and Pass 2.
|
||||
|
||||
The smallest useful end-to-end test is:
|
||||
|
||||
1. Start an isolated backend under `sandbox-exec` with `OPENCODE_DB=:memory:`, simulation provider, fake MCP, guarded network, and seeded mock filesystem.
|
||||
2. Mount the TUI with a harness hook and in-process fetch/event transport.
|
||||
3. Enqueue an LLM script through simulation control.
|
||||
4. Set the prompt to ordinary text like `hello` and submit through `PromptRef`.
|
||||
5. Wait for session idle and TUI sync.
|
||||
6. Assert no TUI/backend errors and that the expected assistant text/tool part appears.
|
||||
7. Persist a replay trace containing the seed, control call, UI action, requests, events, and observations.
|
||||
|
||||
This gives immediate value while leaving room for semantic graph discovery, directed properties, failure shrinking, and true DST controls later.
|
||||
@@ -65,24 +65,24 @@ export type ActiveOrg = {
|
||||
org: Org
|
||||
}
|
||||
|
||||
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
|
||||
export class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
|
||||
config: Schema.Record(Schema.String, Schema.Json),
|
||||
}) {}
|
||||
|
||||
const DurationFromSeconds = Schema.Number.pipe(
|
||||
export const DurationFromSeconds = Schema.Number.pipe(
|
||||
Schema.decodeTo(Schema.Duration, {
|
||||
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
|
||||
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
|
||||
}),
|
||||
)
|
||||
|
||||
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
|
||||
export class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
|
||||
access_token: AccessToken,
|
||||
refresh_token: RefreshToken,
|
||||
expires_in: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
|
||||
export class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
|
||||
device_code: DeviceCode,
|
||||
user_code: UserCode,
|
||||
verification_uri_complete: Schema.String,
|
||||
@@ -90,14 +90,14 @@ class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
|
||||
interval: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
|
||||
export class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
|
||||
access_token: AccessToken,
|
||||
refresh_token: RefreshToken,
|
||||
token_type: Schema.Literal("Bearer"),
|
||||
expires_in: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
|
||||
export class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
|
||||
error: Schema.String,
|
||||
error_description: Schema.String,
|
||||
}) {
|
||||
@@ -110,22 +110,22 @@ class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError"
|
||||
}
|
||||
}
|
||||
|
||||
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
|
||||
export const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
|
||||
|
||||
class User extends Schema.Class<User>("User")({
|
||||
export class User extends Schema.Class<User>("User")({
|
||||
id: AccountID,
|
||||
email: Schema.String,
|
||||
}) {}
|
||||
|
||||
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
|
||||
export class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
|
||||
|
||||
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
|
||||
export class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
|
||||
grant_type: Schema.String,
|
||||
device_code: DeviceCode,
|
||||
client_id: Schema.String,
|
||||
}) {}
|
||||
|
||||
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
|
||||
export class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
|
||||
grant_type: Schema.String,
|
||||
refresh_token: RefreshToken,
|
||||
client_id: Schema.String,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>",
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 + "!",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
import * as Clipboard from "@tui/util/clipboard"
|
||||
import * as Selection from "@tui/util/selection"
|
||||
import * as TuiAudio from "@tui/util/audio"
|
||||
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
|
||||
import { createCliRenderer, MouseButton, type CliRenderer, type CliRendererConfig } from "@opentui/core"
|
||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||
import {
|
||||
Switch,
|
||||
@@ -68,6 +68,7 @@ import { createTuiAttention } from "@/cli/cmd/tui/attention"
|
||||
import { FormatError, FormatUnknownError } from "@/cli/error"
|
||||
import { CommandPaletteProvider, useCommandPalette } from "./context/command-palette"
|
||||
import { OpencodeKeymapProvider, registerOpencodeKeymap, useBindings, useOpencodeKeymap } from "./keymap"
|
||||
import { DiffViewer } from "./routes/diff"
|
||||
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { DialogVariant } from "./component/dialog-variant"
|
||||
@@ -76,8 +77,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",
|
||||
@@ -104,6 +103,7 @@ const appBindingCommands = [
|
||||
"theme.switch",
|
||||
"theme.switch_mode",
|
||||
"theme.mode.lock",
|
||||
"diff.open",
|
||||
"help.show",
|
||||
"docs.open",
|
||||
"app.debug",
|
||||
@@ -167,6 +167,10 @@ export function tui(input: {
|
||||
fetch?: typeof fetch
|
||||
headers?: RequestInit["headers"]
|
||||
events?: EventSource
|
||||
renderer?: CliRenderer
|
||||
mode?: "dark" | "light"
|
||||
onReady?: (ctx: { renderer: CliRenderer }) => void | Promise<void | { simulationMcpUrl?: string }>
|
||||
onStop?: (stop: () => Promise<void>) => void
|
||||
}) {
|
||||
// promise to prevent immediate exit
|
||||
// oxlint-disable-next-line no-async-promise-executor -- intentional: async executor used for sequential setup before resolve
|
||||
@@ -184,10 +188,11 @@ export function tui(input: {
|
||||
TuiAudio.dispose()
|
||||
}
|
||||
|
||||
const renderer = await createCliRenderer(rendererConfig(input.config))
|
||||
const renderer = input.renderer ?? (await createCliRenderer(rendererConfig(input.config)))
|
||||
const [simulationMcpUrl, setSimulationMcpUrl] = createSignal<string | undefined>()
|
||||
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
|
||||
void renderer.getPalette({ size: 16 }).catch(() => undefined)
|
||||
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
|
||||
const mode = input.mode ?? (await renderer.waitForThemeMode(1000)) ?? "dark"
|
||||
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
const offKeymap = registerOpencodeKeymap(keymap, renderer, input.config)
|
||||
@@ -200,7 +205,7 @@ export function tui(input: {
|
||||
)}
|
||||
>
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ArgsProvider {...input.args} simulationMcpUrl={simulationMcpUrl}>
|
||||
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
@@ -234,7 +239,7 @@ export function tui(input: {
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<App onSnapshot={input.onSnapshot} />
|
||||
<AppLifecycle onSnapshot={input.onSnapshot} onStop={input.onStop} />
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
@@ -258,9 +263,17 @@ export function tui(input: {
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}, renderer)
|
||||
const ready = await input.onReady?.({ renderer })
|
||||
if (ready?.simulationMcpUrl) setSimulationMcpUrl(ready.simulationMcpUrl)
|
||||
})
|
||||
}
|
||||
|
||||
function AppLifecycle(props: { onSnapshot?: () => Promise<string[]>; onStop?: (stop: () => Promise<void>) => void }) {
|
||||
const exit = useExit()
|
||||
props.onStop?.(() => exit())
|
||||
return <App onSnapshot={props.onSnapshot} />
|
||||
}
|
||||
|
||||
function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
const tuiConfig = useTuiConfig()
|
||||
const route = useRoute()
|
||||
@@ -341,6 +354,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
renderer.clearSelection()
|
||||
}
|
||||
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
|
||||
const [diffOpen, setDiffOpen] = createSignal(false)
|
||||
const [pasteSummaryEnabled, setPasteSummaryEnabled] = createSignal(
|
||||
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary),
|
||||
)
|
||||
@@ -481,37 +495,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",
|
||||
@@ -674,6 +666,16 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
name: "diff.open",
|
||||
title: "Open diff viewer",
|
||||
slashName: "diff",
|
||||
run: () => {
|
||||
setDiffOpen(true)
|
||||
dialog.clear()
|
||||
},
|
||||
category: "VCS",
|
||||
},
|
||||
{
|
||||
name: "help.show",
|
||||
title: "Help",
|
||||
@@ -826,14 +828,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 +885,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")
|
||||
@@ -979,6 +975,9 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
<TuiPluginRuntime.Slot name="app_bottom" />
|
||||
</box>
|
||||
<TuiPluginRuntime.Slot name="app" />
|
||||
<Show when={diffOpen()}>
|
||||
<DiffViewer onClose={() => setDiffOpen(false)} />
|
||||
</Show>
|
||||
</Show>
|
||||
<StartupLoading ready={ready} />
|
||||
</box>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -67,6 +67,7 @@ export const Definitions = {
|
||||
sidebar_toggle: keybind("<leader>b", "Toggle sidebar"),
|
||||
scrollbar_toggle: keybind("none", "Toggle session scrollbar"),
|
||||
status_view: keybind("<leader>s", "View status"),
|
||||
diff_open: keybind("<leader>d", "Open diff viewer"),
|
||||
|
||||
session_export: keybind("<leader>x", "Export session to editor"),
|
||||
session_copy: keybind("none", "Copy session transcript"),
|
||||
@@ -87,9 +88,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"),
|
||||
@@ -254,6 +252,7 @@ export const CommandMap = {
|
||||
sidebar_toggle: "session.sidebar.toggle",
|
||||
scrollbar_toggle: "session.toggle.scrollbar",
|
||||
status_view: "opencode.status",
|
||||
diff_open: "diff.open",
|
||||
session_export: "session.export",
|
||||
session_copy: "session.copy",
|
||||
session_new: "session.new",
|
||||
@@ -273,9 +272,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") {
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface Args {
|
||||
model?: string
|
||||
agent?: string
|
||||
prompt?: string
|
||||
simulationMcpUrl?: () => string | undefined
|
||||
continue?: boolean
|
||||
sessionID?: string
|
||||
fork?: boolean
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user