Compare commits

..

46 Commits

Author SHA1 Message Date
Dax Raad b246824fdb chore remove snapshot install smoke test 2026-05-15 18:37:19 -04:00
Dax Raad e9db6c4209 fix npm cli binary installation 2026-05-15 18:33:39 -04:00
opencode-agent[bot] da495fd2e0 chore: generate 2026-05-15 22:09:43 +00:00
vimtor 85cd447910 chore: reduce alerts noise 2026-05-16 00:07:59 +02:00
Sebastian 0f31fd631b Fix multiline mentions (#27649) 2026-05-15 23:04:20 +02:00
Sebastian aa07e21945 handle undefined tips (#27635) 2026-05-15 23:04:00 +02:00
Shoubhit Dash f060874b29 feat(tui): add minimal thinking mode with click-to-expand (#27623) 2026-05-16 02:09:58 +05:30
vimtor f21c582db9 chore: reduce alerting noise 2026-05-15 22:18:50 +02:00
Shoubhit Dash 65f96a5851 refactor(instance): retire WithInstance adapter (#27782) 2026-05-16 01:30:07 +05:30
Aiden Cline 48122b31cc fix(tool): bridge custom tool zod metadata (#27770) 2026-05-15 14:50:21 -05:00
opencode-agent[bot] 0df2f5b45f chore: generate 2026-05-15 19:48:45 +00:00
Shoubhit Dash 499e8e4b78 test(instance): add effect-native fixture helpers (#27781) 2026-05-16 01:17:37 +05:30
Shoubhit Dash f33b4455a1 feat(tui): enable pinned session switching (#27780) 2026-05-16 01:10:16 +05:30
Shoubhit Dash a24abd2b11 refactor(lsp): require explicit instance context (#27767) 2026-05-16 00:46:27 +05:30
opencode-agent[bot] d44bef2107 chore: generate 2026-05-15 18:45:24 +00:00
Shoubhit Dash f99339e525 fix(tui): keep session switching pinned-only (#27775) 2026-05-16 00:14:07 +05:30
Shoubhit Dash 2b0e72ab79 refactor(workspace): centralize adapter invocation (#27768) 2026-05-15 23:59:01 +05:30
Shoubhit Dash 2fdee50b3b refactor(acp): extract runtime reentry (#27769) 2026-05-15 23:58:52 +05:30
opencode-agent[bot] 48293c5271 chore: generate 2026-05-15 17:37:07 +00:00
Shoubhit Dash 0c9cfe923f refactor(instance): remove legacy runtime fallback (#27757) 2026-05-15 23:05:44 +05:30
opencode-agent[bot] 9975c1ed1c chore: generate 2026-05-15 15:12:20 +00:00
Aiden Cline ef7d801271 fix(tool): preserve custom tool arg descriptions (#27750)
Co-authored-by: khimaros <231498+khimaros@users.noreply.github.com>
2026-05-15 10:11:01 -05:00
opencode-agent[bot] eb630075c3 chore: generate 2026-05-15 15:01:55 +00:00
Shoubhit Dash a2392ca60d refactor(worktree): provide runtime reentry refs (#27754) 2026-05-15 20:30:29 +05:30
opencode-agent[bot] f9371eb66c chore: generate 2026-05-15 14:36:04 +00:00
Shoubhit Dash fa9a2cb24d refactor(instance): remove remaining bind call sites (#27731) 2026-05-15 20:04:42 +05:30
Victor Navarro 2d90f325fc ci: catch provider errors across all opencode tiers (#27495) 2026-05-15 16:31:59 +02:00
Aiden Cline c2ffd7cf14 fix: markdown table rendering (#27747) 2026-05-15 09:22:24 -05:00
vimtor 104f5d5a14 chore: exclude provider from triggers 2026-05-15 16:01:11 +02:00
Shoubhit Dash 1c7c03332e test(workspace): avoid legacy instance reads (#27727) 2026-05-15 17:07:21 +05:30
opencode-agent[bot] 984eefa6f8 chore: generate 2026-05-15 11:02:14 +00:00
Shoubhit Dash bf64f8cbb5 refactor(cli): dispose bootstrap instance explicitly (#27721) 2026-05-15 16:30:54 +05:30
opencode-agent[bot] 727a83aa7a chore: generate 2026-05-15 10:46:06 +00:00
Shoubhit Dash e65383810a refactor(tool): read repo overview directory from instance state (#27717) 2026-05-15 16:14:49 +05:30
Shoubhit Dash 12b666e2c9 refactor(project): import instance context directly (#27714) 2026-05-15 15:59:56 +05:30
Shoubhit Dash eb5ef1c073 refactor(flags): remove unused flag exports (#27709) 2026-05-15 15:35:24 +05:30
Shoubhit Dash 356f684186 refactor(flags): migrate skip migrations flag (#27705) 2026-05-15 14:54:29 +05:30
Shoubhit Dash 7b370406a9 refactor(flags): migrate lsp download flag (#27699) 2026-05-15 14:35:31 +05:30
Shoubhit Dash 202cc863b4 refactor(flags): migrate claude code prompt flag (#27690) 2026-05-15 14:17:04 +05:30
Shoubhit Dash 22cb0395e2 refactor(flags): migrate external skills flag (#27685) 2026-05-15 13:24:56 +05:30
Shoubhit Dash 2d6bedecd4 refactor(flags): migrate output token max to runtime flags (#27680) 2026-05-15 13:07:35 +05:30
opencode-agent[bot] 2080390ca6 chore: generate 2026-05-15 07:36:10 +00:00
Kagura 1ac3f09468 fix(watcher): resolve symlinked .git path before subscribing (#27016)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-05-15 09:34:53 +02:00
Aiden Cline ca8f578f2f ci: skip previously cleaned PRs (#27670) 2026-05-15 00:23:09 -05:00
Aiden Cline d59d99665b ci: Automate PR cleanup (#27667) 2026-05-14 23:47:59 -05:00
opencode c43edc5b71 sync release versions for v1.15.0 2026-05-15 04:03:54 +00:00
101 changed files with 2835 additions and 2331 deletions
+50
View File
@@ -0,0 +1,50 @@
name: close-prs
on:
schedule:
- cron: "0 22 * * *" # Daily at 10:00 PM UTC
workflow_dispatch:
inputs:
dry-run:
description: "Log matching PRs without closing them"
type: boolean
default: true
max-close:
description: "Maximum matching PRs to close"
type: string
required: false
default: "50"
jobs:
close:
runs-on: ubuntu-latest
timeout-minutes: 240
permissions:
contents: read
issues: write
pull-requests: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: Close old PRs without enough positive reactions
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
max_close="${{ inputs['max-close'] }}"
if [ -z "$max_close" ]; then
max_close="50"
fi
args=("--threshold" "2" "--age-months" "1" "--sleep-ms" "20000" "--max-close" "$max_close")
if [ "${{ github.event_name }}" = "schedule" ]; then
args+=("--execute")
elif [ "${{ inputs['dry-run'] }}" = "false" ]; then
args+=("--execute")
fi
bun script/github/close-prs.ts "${args[@]}"
-235
View File
@@ -1,235 +0,0 @@
name: close-stale-prs
on:
workflow_dispatch:
inputs:
dryRun:
description: "Log actions without closing PRs"
type: boolean
default: false
schedule:
- cron: "0 6 * * *"
permissions:
contents: read
issues: write
pull-requests: write
jobs:
close-stale-prs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Close inactive PRs
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const DAYS_INACTIVE = 60
const MAX_RETRIES = 3
// Adaptive delay: fast for small batches, slower for large to respect
// GitHub's 80 content-generating requests/minute limit
const SMALL_BATCH_THRESHOLD = 10
const SMALL_BATCH_DELAY_MS = 1000 // 1s for daily operations (≤10 PRs)
const LARGE_BATCH_DELAY_MS = 2000 // 2s for backlog (>10 PRs) = ~30 ops/min, well under 80 limit
const startTime = Date.now()
const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000)
const { owner, repo } = context.repo
const dryRun = context.payload.inputs?.dryRun === "true"
core.info(`Dry run mode: ${dryRun}`)
core.info(`Cutoff date: ${cutoff.toISOString()}`)
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function withRetry(fn, description = 'API call') {
let lastError
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const result = await fn()
return result
} catch (error) {
lastError = error
const isRateLimited = error.status === 403 &&
(error.message?.includes('rate limit') || error.message?.includes('secondary'))
if (!isRateLimited) {
throw error
}
// Parse retry-after header, default to 60 seconds
const retryAfter = error.response?.headers?.['retry-after']
? parseInt(error.response.headers['retry-after'])
: 60
// Exponential backoff: retryAfter * 2^attempt
const backoffMs = retryAfter * 1000 * Math.pow(2, attempt)
core.warning(`${description}: Rate limited (attempt ${attempt + 1}/${MAX_RETRIES}). Waiting ${backoffMs / 1000}s before retry...`)
await sleep(backoffMs)
}
}
core.error(`${description}: Max retries (${MAX_RETRIES}) exceeded`)
throw lastError
}
const query = `
query($owner: String!, $repo: String!, $cursor: String) {
repository(owner: $owner, name: $repo) {
pullRequests(first: 100, states: OPEN, after: $cursor) {
pageInfo {
hasNextPage
endCursor
}
nodes {
number
title
author {
login
}
createdAt
commits(last: 1) {
nodes {
commit {
committedDate
}
}
}
comments(last: 1) {
nodes {
createdAt
}
}
reviews(last: 1) {
nodes {
createdAt
}
}
}
}
}
}
`
const allPrs = []
let cursor = null
let hasNextPage = true
let pageCount = 0
while (hasNextPage) {
pageCount++
core.info(`Fetching page ${pageCount} of open PRs...`)
const result = await withRetry(
() => github.graphql(query, { owner, repo, cursor }),
`GraphQL page ${pageCount}`
)
allPrs.push(...result.repository.pullRequests.nodes)
hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage
cursor = result.repository.pullRequests.pageInfo.endCursor
core.info(`Page ${pageCount}: fetched ${result.repository.pullRequests.nodes.length} PRs (total: ${allPrs.length})`)
// Delay between pagination requests (use small batch delay for reads)
if (hasNextPage) {
await sleep(SMALL_BATCH_DELAY_MS)
}
}
core.info(`Found ${allPrs.length} open pull requests`)
const stalePrs = allPrs.filter((pr) => {
const dates = [
new Date(pr.createdAt),
pr.commits.nodes[0] ? new Date(pr.commits.nodes[0].commit.committedDate) : null,
pr.comments.nodes[0] ? new Date(pr.comments.nodes[0].createdAt) : null,
pr.reviews.nodes[0] ? new Date(pr.reviews.nodes[0].createdAt) : null,
].filter((d) => d !== null)
const lastActivity = dates.sort((a, b) => b.getTime() - a.getTime())[0]
if (!lastActivity || lastActivity > cutoff) {
core.info(`PR #${pr.number} is fresh (last activity: ${lastActivity?.toISOString() || "unknown"})`)
return false
}
core.info(`PR #${pr.number} is STALE (last activity: ${lastActivity.toISOString()})`)
return true
})
if (!stalePrs.length) {
core.info("No stale pull requests found.")
return
}
core.info(`Found ${stalePrs.length} stale pull requests`)
// ============================================
// Close stale PRs
// ============================================
const requestDelayMs = stalePrs.length > SMALL_BATCH_THRESHOLD
? LARGE_BATCH_DELAY_MS
: SMALL_BATCH_DELAY_MS
core.info(`Using ${requestDelayMs}ms delay between operations (${stalePrs.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`)
let closedCount = 0
let skippedCount = 0
for (const pr of stalePrs) {
const issue_number = pr.number
const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.`
if (dryRun) {
core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
continue
}
try {
// Add comment
await withRetry(
() => github.rest.issues.createComment({
owner,
repo,
issue_number,
body: closeComment,
}),
`Comment on PR #${issue_number}`
)
// Close PR
await withRetry(
() => github.rest.pulls.update({
owner,
repo,
pull_number: issue_number,
state: "closed",
}),
`Close PR #${issue_number}`
)
closedCount++
core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
// Delay before processing next PR
await sleep(requestDelayMs)
} catch (error) {
skippedCount++
core.error(`Failed to close PR #${issue_number}: ${error.message}`)
}
}
const elapsed = Math.round((Date.now() - startTime) / 1000)
core.info(`\n========== Summary ==========`)
core.info(`Total open PRs found: ${allPrs.length}`)
core.info(`Stale PRs identified: ${stalePrs.length}`)
core.info(`PRs closed: ${closedCount}`)
core.info(`PRs skipped (errors): ${skippedCount}`)
core.info(`Elapsed time: ${elapsed}s`)
core.info(`=============================`)
+10 -26
View File
@@ -70,11 +70,10 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => {
}).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 +100,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 +218,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 }],
+1 -25
View File
@@ -5,15 +5,7 @@ function truthy(key: string) {
return value === "true" || value === "1"
}
function number(key: string) {
const value = process.env[key]
if (!value) return undefined
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
}
const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
const OPENCODE_DISABLE_CLAUDE_CODE = truthy("OPENCODE_DISABLE_CLAUDE_CODE")
const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
export const Flag = {
@@ -30,21 +22,14 @@ export const Flag = {
OPENCODE_DISABLE_TERMINAL_TITLE: truthy("OPENCODE_DISABLE_TERMINAL_TITLE"),
OPENCODE_SHOW_TTFD: truthy("OPENCODE_SHOW_TTFD"),
OPENCODE_PERMISSION: process.env["OPENCODE_PERMISSION"],
OPENCODE_DISABLE_DEFAULT_PLUGINS: truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS"),
OPENCODE_DISABLE_LSP_DOWNLOAD: truthy("OPENCODE_DISABLE_LSP_DOWNLOAD"),
OPENCODE_DISABLE_AUTOCOMPACT: truthy("OPENCODE_DISABLE_AUTOCOMPACT"),
OPENCODE_DISABLE_MODELS_FETCH: truthy("OPENCODE_DISABLE_MODELS_FETCH"),
OPENCODE_DISABLE_MOUSE: truthy("OPENCODE_DISABLE_MOUSE"),
OPENCODE_DISABLE_CLAUDE_CODE,
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
OPENCODE_DISABLE_EXTERNAL_SKILLS: truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"],
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
OPENCODE_ENABLE_QUESTION_TOOL: truthy("OPENCODE_ENABLE_QUESTION_TOOL"),
// Experimental
OPENCODE_EXPERIMENTAL,
OPENCODE_EXPERIMENTAL_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_FILEWATCHER").pipe(
Config.withDefault(false),
),
@@ -53,22 +38,13 @@ 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_EXPERIMENTAL_MINIMAL_THINKING: truthy("OPENCODE_EXPERIMENTAL_MINIMAL_THINKING"),
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.
+3 -12
View File
@@ -128,17 +128,8 @@ See `specs/effect/migration.md` for the compact pattern reference and examples.
Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation rather than storing `Fiber | undefined` or `Promise | undefined` manually. See `specs/effect/migration.md` for the full pattern.
## Instance.bind — ALS for native callbacks
## Callback boundaries
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called.
Use `EffectBridge` for native or external callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, plugin callbacks, etc.) that need to re-enter Effect services with instance/workspace context.
Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish` or anything that reads `Instance.directory`.
You do not need it for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers.
```typescript
const cb = Instance.bind((err, evts) => {
Bus.publish(MyEvent, { ... })
})
nativeAddon.subscribe(dir, cb)
```
Plain async code should pass explicit context or stay inside an Effect fiber; do not add ambient instance context shims.
+1
View File
@@ -244,6 +244,7 @@ for (const item of targets) {
{
name,
version: Script.version,
preferUnplugged: true,
os: [item.os],
cpu: [item.arch],
},
+167 -80
View File
@@ -1,102 +1,189 @@
#!/usr/bin/env node
import childProcess from "child_process"
import fs from "fs"
import path from "path"
import os from "os"
import { fileURLToPath } from "url"
import path from "path"
import { createRequire } from "module"
import { fileURLToPath } from "url"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const require = createRequire(import.meta.url)
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8"))
function detectPlatformAndArch() {
// Map platform names
let platform
switch (os.platform()) {
case "darwin":
platform = "darwin"
break
case "linux":
platform = "linux"
break
case "win32":
platform = "windows"
break
default:
platform = os.platform()
break
}
// Map architecture names
let arch
switch (os.arch()) {
case "x64":
arch = "x64"
break
case "arm64":
arch = "arm64"
break
case "arm":
arch = "arm"
break
default:
arch = os.arch()
break
}
return { platform, arch }
const platformMap = {
darwin: "darwin",
linux: "linux",
win32: "windows",
}
const archMap = {
x64: "x64",
arm64: "arm64",
arm: "arm",
}
function findBinary() {
const { platform, arch } = detectPlatformAndArch()
const packageName = `opencode-${platform}-${arch}`
const binaryName = platform === "windows" ? "opencode.exe" : "opencode"
const platform = platformMap[os.platform()] ?? os.platform()
const arch = archMap[os.arch()] ?? os.arch()
const base = `opencode-${platform}-${arch}`
const sourceBinary = platform === "windows" ? "opencode.exe" : "opencode"
const targetBinary = path.join(__dirname, "bin", "opencode.exe")
try {
// Use require.resolve to find the package
const packageJsonPath = require.resolve(`${packageName}/package.json`)
const packageDir = path.dirname(packageJsonPath)
const binaryPath = path.join(packageDir, "bin", binaryName)
function supportsAvx2() {
if (arch !== "x64") return false
if (!fs.existsSync(binaryPath)) {
throw new Error(`Binary not found at ${binaryPath}`)
}
return { binaryPath, binaryName }
} catch (error) {
throw new Error(`Could not find package ${packageName}: ${error.message}`, { cause: error })
}
}
async function main() {
try {
if (os.platform() === "win32") {
// On Windows, the .exe is already included in the package and bin field points to it
// No postinstall setup needed
console.log("Windows detected: binary setup not needed (using packaged .exe)")
return
}
// On non-Windows platforms, just verify the binary package exists
// Don't replace the wrapper script - it handles binary execution
const { binaryPath } = findBinary()
const target = path.join(__dirname, "bin", ".opencode")
if (fs.existsSync(target)) fs.unlinkSync(target)
if (platform === "linux") {
try {
fs.linkSync(binaryPath, target)
return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8"))
} catch {
fs.copyFileSync(binaryPath, target)
return false
}
fs.chmodSync(target, 0o755)
} catch (error) {
console.error("Failed to setup opencode binary:", error.message)
process.exit(1)
}
if (platform === "darwin") {
try {
const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], {
encoding: "utf8",
timeout: 1500,
})
if (result.status !== 0) return false
return (result.stdout || "").trim() === "1"
} catch {
return false
}
}
if (platform === "windows") {
const command =
'(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)'
for (const executable of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) {
try {
const result = childProcess.spawnSync(executable, ["-NoProfile", "-NonInteractive", "-Command", command], {
encoding: "utf8",
timeout: 3000,
windowsHide: true,
})
if (result.status !== 0) continue
const output = (result.stdout || "").trim().toLowerCase()
if (output === "true" || output === "1") return true
if (output === "false" || output === "0") return false
} catch {
continue
}
}
}
return false
}
function isMusl() {
if (platform !== "linux") return false
try {
if (fs.existsSync("/etc/alpine-release")) return true
} catch {
// Ignore filesystem probes that are blocked by the host.
}
try {
const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" })
return `${result.stdout || ""}${result.stderr || ""}`.toLowerCase().includes("musl")
} catch {
return false
}
}
function packageNames() {
const baseline = arch === "x64" && !supportsAvx2()
if (platform === "linux") {
if (isMusl()) {
if (arch === "x64")
return baseline
? [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base]
: [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`]
return [`${base}-musl`, base]
}
if (arch === "x64")
return baseline
? [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`]
: [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`]
return [base, `${base}-musl`]
}
if (arch === "x64") return baseline ? [`${base}-baseline`, base] : [base, `${base}-baseline`]
return [base]
}
function resolveBinary(name) {
const packageJsonPath = require.resolve(`${name}/package.json`)
const binaryPath = path.join(path.dirname(packageJsonPath), "bin", sourceBinary)
if (!fs.existsSync(binaryPath)) throw new Error(`Binary not found at ${binaryPath}`)
return binaryPath
}
function installPackage(name) {
const version = packageJson.optionalDependencies?.[name]
if (!version) return
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-install-"))
try {
const result = childProcess.spawnSync(
"npm",
["install", "--ignore-scripts", "--no-save", "--loglevel=error", "--prefix", temp, `${name}@${version}`],
{ stdio: "inherit", windowsHide: true },
)
if (result.status !== 0) return
const packageDir = path.join(temp, "node_modules", name)
copyBinary(path.join(packageDir, "bin", sourceBinary), targetBinary)
return true
} finally {
fs.rmSync(temp, { recursive: true, force: true })
}
}
function copyBinary(source, target) {
if (!fs.existsSync(source)) throw new Error(`Binary not found at ${source}`)
fs.mkdirSync(path.dirname(target), { recursive: true })
if (fs.existsSync(target)) fs.unlinkSync(target)
try {
fs.linkSync(source, target)
} catch {
fs.copyFileSync(source, target)
}
fs.chmodSync(target, 0o755)
}
function verifyBinary() {
const result = childProcess.spawnSync(targetBinary, ["--version"], {
encoding: "utf8",
stdio: "ignore",
windowsHide: true,
})
return result.status === 0
}
function main() {
for (const name of packageNames()) {
try {
copyBinary(resolveBinary(name), targetBinary)
if (verifyBinary()) return
} catch {
if (installPackage(name) && verifyBinary()) return
}
}
throw new Error(
`It seems your package manager failed to install the right opencode CLI package. Try manually installing ${packageNames()
.map((name) => JSON.stringify(name))
.join(" or ")}.`,
)
}
try {
void main()
main()
} catch (error) {
console.error("Postinstall script error:", error.message)
process.exit(0)
console.error(error.message)
process.exit(1)
}
+13 -3
View File
@@ -32,22 +32,32 @@ 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(
[
"#!/usr/bin/env node",
"console.error('The opencode native binary was not installed. Run `node postinstall.mjs` from the opencode-ai package directory to finish setup.')",
"process.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,
@@ -6,7 +6,6 @@ Current status on this branch:
- `src/` has 5 `makeRuntime(...)` call sites total.
- 2 are intentionally excluded from this checklist: `src/bus/index.ts` and `src/effect/cross-spawn-spawner.ts`.
- 1 is tracked primarily by the instance-context migration rather than facade removal: `src/project/instance.ts`.
- That leaves 2 live runtime-backed service facades still worth tracking here: `src/npm/index.ts` and `src/cli/cmd/tui/config/tui.ts`.
Recent progress:
@@ -18,7 +17,6 @@ Recent progress:
- `src/cli/cmd/tui/config/tui.ts` still exports `makeRuntime(...)` plus async facade helpers for `get()` and `waitForDependencies()`.
- `src/npm/index.ts` still exports `makeRuntime(...)` plus async facade helpers for `install()`, `add()`, `outdated()`, and `which()`.
- `src/project/instance.ts` still uses a dedicated runtime for project boot, but that file is really part of the broader legacy instance-context transition tracked in `instance-context.md`.
## Completed Batches
@@ -192,7 +190,6 @@ Most of the original facade-removal backlog is already done. The practical remai
1. remove the `Npm` runtime-backed facade from `src/npm/index.ts`
2. remove the `TuiConfig` runtime-backed facade from `src/cli/cmd/tui/config/tui.ts`
3. keep `src/project/instance.ts` in the separate instance-context migration, not this checklist
## Checklist
+3 -7
View File
@@ -197,13 +197,9 @@ For background loops, use `Effect.repeat` or `Effect.schedule` with
[`EffectBridge`](../../src/effect/bridge.ts) is the sanctioned helper for
Promise/callback interop that needs to preserve instance/workspace context.
Keep it, but reduce its dependency on legacy `Instance.current` /
`Instance.restore` over time.
`Instance.bind` / `Instance.restore` are transitional legacy tools. Use
them only for native callbacks that still require legacy ALS context. Do
not use them for `setTimeout`, `Promise.then`, `EventEmitter.on`, or
Effect fibers.
It preserves explicit `InstanceRef` / `WorkspaceRef` context for effects run
through the bridge. Plain JS callbacks that need instance data should receive
that data explicitly.
## Testing
@@ -1,309 +1,13 @@
# Instance context migration
# Instance Context
Practical plan for retiring the promise-backed / ALS-backed `Instance` helper in `src/project/instance.ts` and moving instance selection fully into Effect-provided scope.
Instance selection is now Effect-provided context.
## Goal
Use these APIs:
End state:
- `InstanceRef` for the current project context.
- `WorkspaceRef` for the current workspace id.
- `InstanceState.context` / `InstanceState.directory` inside Effect services that require an instance.
- `InstanceStore` at entry boundaries that need to load, reload, or dispose project contexts.
- `EffectBridge` for native, plugin, or plain JavaScript callback boundaries that need to re-enter Effect with captured refs.
- request, CLI, TUI, and tool entrypoints shift into an instance through Effect, not `Instance.provide(...)`
- Effect code reads the current instance from `InstanceRef` or its eventual replacement, not from ALS-backed sync getters
- per-directory boot, caching, and disposal are scoped Effect resources, not a module-level `Map<string, Promise<InstanceContext>>`
- ALS remains only as a temporary bridge for native callback APIs that fire outside the Effect fiber tree
## Current split
Today `src/project/instance.ts` still owns two separate concerns:
- ambient current-instance context through `LocalContext` / `AsyncLocalStorage`
- per-directory boot and deduplication through `cache: Map<string, Promise<InstanceContext>>`
At the same time, the Effect side already exists:
- `src/effect/instance-ref.ts` provides `InstanceRef` and `WorkspaceRef`
- `src/effect/run-service.ts` already attaches those refs when a runtime starts inside an active instance ALS context
- `src/effect/instance-state.ts` already prefers `InstanceRef` and only falls back to ALS when needed
That means the migration is not "invent instance context in Effect". The migration is "stop relying on the legacy helper as the primary source of truth".
## End state shape
Near-term target shape:
```ts
InstanceScope.with({ directory, workspaceID }, effect)
```
Responsibilities of `InstanceScope.with(...)`:
- resolve `directory`, `project`, and `worktree`
- acquire or reuse the scoped per-directory instance environment
- provide `InstanceRef` and `WorkspaceRef`
- run the caller's Effect inside that environment
Code inside the boundary should then do one of these:
```ts
const ctx = yield * InstanceState.context
const dir = yield * InstanceState.directory
```
Long-term, once `InstanceState` itself is replaced by keyed layers / `LayerMap`, those reads can move to an `InstanceContext` service without changing the outer migration order.
## Migration phases
### Phase 1: stop expanding the legacy surface
Rules for all new code:
- do not add new `Instance.directory`, `Instance.worktree`, `Instance.project`, or `Instance.current` reads inside Effect code
- do not add new `Instance.provide(...)` boundaries unless there is no Effect-native seam yet
- use `InstanceState.context`, `InstanceState.directory`, or an explicit `ctx` parameter inside Effect code
Success condition:
- the file inventory below only shrinks from here
### Phase 2: remove direct sync getter reads from Effect services
Convert Effect services first, before replacing the top-level boundary. These modules already run inside Effect and mostly need `yield* InstanceState.context` or a yielded `ctx` instead of ambient sync access.
Primary batch, highest payoff:
- `src/file/index.ts`
- `src/lsp/server.ts`
- `src/worktree/index.ts`
- `src/file/watcher.ts`
- `src/format/formatter.ts`
- `src/session/index.ts`
- `src/project/vcs.ts`
Mechanical replacement rule:
- `Instance.directory` -> `ctx.directory` or `yield* InstanceState.directory`
- `Instance.worktree` -> `ctx.worktree`
- `Instance.project` -> `ctx.project`
Do not thread strings manually through every public method if the service already has access to Effect context.
### Phase 3: convert entry boundaries to provide instance refs directly
After the service bodies stop assuming ALS, move the top-level boundaries to shift into Effect explicitly.
Main boundaries:
- HTTP server middleware and experimental `HttpApi` entrypoints
- CLI commands
- TUI worker / attach / thread entrypoints
- tool execution entrypoints
These boundaries should become Effect-native wrappers that:
- decode directory / workspace inputs
- resolve the instance context once
- provide `InstanceRef` and `WorkspaceRef`
- run the requested Effect
At that point `Instance.provide(...)` becomes a legacy adapter instead of the normal code path.
### Phase 4: replace promise boot cache with scoped instance runtime
Once boundaries and services both rely on Effect context, replace the module-level promise cache in `src/project/instance.ts`.
Target replacement:
- keyed scoped runtime or keyed layer acquisition for each directory
- reuse via `ScopedCache`, `LayerMap`, or another keyed Effect resource manager
- cleanup performed by scope finalizers instead of `disposeAll()` iterating a Promise map
This phase should absorb the current responsibilities of:
- `cache` in `src/project/instance.ts`
- `boot(...)`
- most of `disposeInstance(...)`
- manual `reload(...)` / `disposeAll()` fan-out logic
### Phase 5: shrink ALS to callback bridges only
Keep ALS only where a library invokes callbacks outside the Effect fiber tree and we still need to call code that reads instance context synchronously.
Known bridge cases today:
- `src/file/watcher.ts`
- `src/session/llm.ts`
- some LSP and plugin callback paths
If those libraries become fully wrapped in Effect services, the remaining `Instance.bind(...)` uses can disappear too.
### Phase 6: delete the legacy sync API
Only after earlier phases land:
- remove broad use of `Instance.current`, `Instance.directory`, `Instance.worktree`, `Instance.project`
- reduce `src/project/instance.ts` to a thin compatibility shim or delete it entirely
- remove the ALS fallback from `InstanceState.context`
## Inventory of direct legacy usage
Direct legacy usage means any source file that still calls one of:
- `Instance.current`
- `Instance.directory`
- `Instance.worktree`
- `Instance.project`
- `Instance.provide(...)`
- `Instance.bind(...)`
- `Instance.restore(...)`
- `Instance.reload(...)`
- `Instance.dispose()` / `Instance.disposeAll()`
Current total: `56` files in `packages/opencode/src`.
### Core bridge and plumbing
These files define or adapt the current bridge. They should change last, after callers have moved.
- `src/project/instance.ts`
- `src/effect/run-service.ts`
- `src/effect/instance-state.ts`
- `src/project/bootstrap.ts`
- `src/config/config.ts`
Migration rule:
- keep these as compatibility glue until the outer boundaries and inner services stop depending on ALS
### HTTP and server boundaries
These are the current request-entry seams that still create or consume instance context through the legacy helper.
- `src/server/routes/instance/middleware.ts`
- `src/server/routes/instance/index.ts`
- `src/server/routes/instance/project.ts`
- `src/server/routes/control/workspace.ts`
- `src/server/routes/instance/file.ts`
- `src/server/routes/instance/experimental.ts`
- `src/server/routes/global.ts`
Migration rule:
- move these to explicit Effect entrypoints that provide `InstanceRef` / `WorkspaceRef`
- do not move these first; first reduce the number of downstream handlers and services that still expect ambient ALS
### CLI and TUI boundaries
These commands still enter an instance through `Instance.provide(...)` or read sync getters directly.
- `src/cli/bootstrap.ts`
- `src/cli/cmd/agent.ts`
- `src/cli/cmd/debug/agent.ts`
- `src/cli/cmd/debug/ripgrep.ts`
- `src/cli/cmd/github.ts`
- `src/cli/cmd/import.ts`
- `src/cli/cmd/mcp.ts`
- `src/cli/cmd/models.ts`
- `src/cli/cmd/plug.ts`
- `src/cli/cmd/pr.ts`
- `src/cli/cmd/providers.ts`
- `src/cli/cmd/stats.ts`
- `src/cli/cmd/tui/attach.ts`
- `src/cli/cmd/tui/plugin/runtime.ts`
- `src/cli/cmd/tui/thread.ts`
- `src/cli/cmd/tui/worker.ts`
Migration rule:
- converge these on one shared `withInstance(...)` Effect entry helper instead of open-coded `Instance.provide(...)`
- after that helper is proven, inline the legacy implementation behind an Effect-native scope provider
### Tool boundary code
These tools mostly use direct getters for path resolution and repo-relative display logic.
- `src/tool/apply_patch.ts`
- `src/tool/bash.ts`
- `src/tool/edit.ts`
- `src/tool/lsp.ts`
- `src/tool/plan.ts`
- `src/tool/read.ts`
- `src/tool/write.ts`
Migration rule:
- expose the current instance as an explicit Effect dependency for tool execution
- keep path logic local; avoid introducing another global singleton for tool state
### Effect services still reading ambient instance state
These modules are already the best near-term migration targets because they are in Effect code but still read sync getters from the legacy helper.
- `src/agent/agent.ts`
- `src/cli/cmd/tui/config/tui-migrate.ts`
- `src/file/index.ts`
- `src/file/watcher.ts`
- `src/format/formatter.ts`
- `src/lsp/client.ts`
- `src/lsp/index.ts`
- `src/lsp/server.ts`
- `src/mcp/index.ts`
- `src/project/vcs.ts`
- `src/provider/provider.ts`
- `src/pty/index.ts`
- `src/session/session.ts`
- `src/session/instruction.ts`
- `src/session/llm.ts`
- `src/session/system.ts`
- `src/sync/index.ts`
- `src/worktree/index.ts`
Migration rule:
- replace direct getter reads with `yield* InstanceState.context` or a yielded `ctx`
- isolate `Instance.bind(...)` callers and convert only the truly callback-driven edges to bridge mode
### Highest-churn hotspots
Current highest direct-usage counts by file:
- `src/file/index.ts` - `18`
- `src/lsp/server.ts` - `14`
- `src/worktree/index.ts` - `12`
- `src/file/watcher.ts` - `9`
- `src/cli/cmd/mcp.ts` - `8`
- `src/format/formatter.ts` - `8`
- `src/tool/apply_patch.ts` - `8`
- `src/cli/cmd/github.ts` - `7`
These files should drive the first measurable burn-down.
## Recommended implementation order
1. Migrate direct getter reads inside Effect services, starting with `file`, `lsp`, `worktree`, `format`, and `session`.
2. Add one shared Effect-native boundary helper for CLI / tool / HTTP entrypoints so we stop open-coding `Instance.provide(...)`.
3. Move experimental `HttpApi` entrypoints to that helper so the new server stack proves the pattern.
4. Convert remaining CLI and tool boundaries.
5. Replace the promise cache with a keyed scoped runtime or keyed layer map.
6. Delete ALS fallback paths once only callback bridges still depend on them.
## Definition of done
This migration is done when all of the following are true:
- new requests and commands enter an instance by providing Effect context, not ALS
- Effect services no longer read `Instance.directory`, `Instance.worktree`, `Instance.project`, or `Instance.current`
- `Instance.provide(...)` is gone from normal request / CLI / tool execution
- per-directory boot and disposal are handled by scoped Effect resources
- `Instance.bind(...)` is either gone or confined to a tiny set of native callback adapters
## Tracker and worktree
Active tracker items:
- `lh7l73` - overall `HttpApi` migration
- `yobwlk` - remove direct `Instance.*` reads inside Effect services
- `7irl1e` - replace `InstanceState` / legacy instance caching with keyed Effect layers
Dedicated worktree for this transition:
- path: `/Users/kit/code/open-source/opencode-worktrees/instance-effect-shift`
- branch: `kit/instance-effect-shift`
Do not add new ambient instance globals. Promise and callback boundaries should either stay in Effect, use `EffectBridge`, or pass the required context explicitly.
@@ -24,10 +24,6 @@ Small follow-ups that do not fit neatly into the main facade, route, tool, or sc
- [ ] `cli/cmd/tui/config/tui.ts` - switch TUI config loading from async `ConfigPaths.*` wrappers to the `ConfigPaths.Service` once that service exists.
- [ ] `cli/cmd/tui/config/tui-migrate.ts` - decide whether to leave this as a plain async module using wrapper functions or effectify it fully after `ConfigPaths.Service` lands.
## Instance cleanup
- [ ] `project/instance.ts` - keep shrinking the legacy ALS / Promise cache after the remaining `Instance.*` callers move over.
## Notes
- Prefer small, semantics-preserving config migrations. Config precedence, legacy key migration, and plugin origin tracking are easy to break accidentally.
+8 -71
View File
@@ -64,13 +64,11 @@ P6 OA
explicit and testable instead of mutable module state.
Shrinks: [`global.ts`](../../../core/src/global.ts) import-time side
effects, mutable `Global.Path` overrides, and its `Flag` dependency.
- `INST` Instance shim — remove ambient `Instance` usage and old ALS
access patterns.
Shrinks: [`src/project/instance.ts`](../../src/project/instance.ts).
- `INST` Instance context — keep project context explicit through Effect refs
and bridge boundaries.
- `BRIDGE` Promise/callback interop — keep bridge helpers, but reduce
legacy ALS coupling.
Shrinks: [`src/effect/bridge.ts`](../../src/effect/bridge.ts)
dependency on [`project/instance.ts`](../../src/project/instance.ts).
Shrinks: ad hoc Promise/callback re-entry code.
- `PROC` AppProcess migration — prefer `AppProcess.Service` over raw
process wrappers.
Shrinks: direct spawn callsites and legacy process helpers.
@@ -221,74 +219,13 @@ Next PR candidates:
## P4: Instance And Bridge
[`project/instance.ts`](../../src/project/instance.ts) is the deletion
target. [`effect/bridge.ts`](../../src/effect/bridge.ts) is not a near-term
deletion target; Promise/callback interop will continue to exist.
Instance context migration is complete for the legacy sync shim. Promise and callback interop continues through [`effect/bridge.ts`](../../src/effect/bridge.ts).
Goal:
Current rules:
- Keep a sanctioned bridge for Promise/callback boundaries.
- Reduce bridge dependence on legacy `Instance.restore` / `Instance.current`.
- Move callers toward `InstanceRef`, `WorkspaceRef`, `InstanceState`, or
explicit context where practical.
- Delete `project/instance.ts` only after ambient Instance coupling is gone.
Important distinction:
- `InstanceState.context`, `InstanceState.directory`, and
`InstanceState.workspaceID` are acceptable inside normal Effect service
code when `InstanceRef` / `WorkspaceRef` are provided by the runtime.
- The deletion blockers are the fallback and callback paths that rely on
ambient ALS: direct `Instance.*` reads, `InstanceState.bind(...)`,
`AppRuntime.runPromise(...)` re-entry from plain JS, and bridge restore
code that installs legacy ALS before invoking callbacks.
Current bottom-up inventory from `dev`:
- Direct `Instance.*` value readers:
[`tool/repo_overview.ts`](../../src/tool/repo_overview.ts),
[`control-plane/adapters/worktree.ts`](../../src/control-plane/adapters/worktree.ts),
[`cli/bootstrap.ts`](../../src/cli/bootstrap.ts).
- `InstanceState.bind(...)` callback boundaries:
[`file/watcher.ts`](../../src/file/watcher.ts) native watcher callback,
[`storage/db.ts`](../../src/storage/db.ts) transaction/effect callbacks,
[`session/llm.ts`](../../src/session/llm.ts) workflow approval callback.
- `AppRuntime.runPromise(...)` / re-entry from plain JS:
[`project/with-instance.ts`](../../src/project/with-instance.ts),
[`project/instance-runtime.ts`](../../src/project/instance-runtime.ts),
[`control-plane/adapters/worktree.ts`](../../src/control-plane/adapters/worktree.ts),
[`cli/effect-cmd.ts`](../../src/cli/effect-cmd.ts), plus global/non-instance
callsites such as CLI upgrade and ACP agent defaults.
- Intentional bridge users to classify, not delete blindly:
workspace adapters in [`control-plane/workspace.ts`](../../src/control-plane/workspace.ts),
MCP, command execution, plugins, pty lifecycle, bus scope cleanup, task
cancellation, and HTTP lifecycle reload/dispose paths.
- Core fallback layer to shrink last:
[`effect/run-service.ts`](../../src/effect/run-service.ts),
[`effect/bridge.ts`](../../src/effect/bridge.ts), and
[`effect/instance-state.ts`](../../src/effect/instance-state.ts).
Recommended PR order:
- [ ] `INST-1` Remove direct `Instance.*` value readers. Start with
`repo_overview`, `worktree` adapter, and `cli/bootstrap`; pass context
explicitly or obtain it from an Effect boundary.
- [ ] `INST-2` Move type-only `InstanceContext` imports from
[`project/instance.ts`](../../src/project/instance.ts) to
[`project/instance-context.ts`](../../src/project/instance-context.ts).
- [ ] `INST-3` Audit each `InstanceState.bind(...)` callback from the inside
out: list what the callback calls (`Bus.publish`, database effects,
permission/session services), then replace ambient capture with explicit
`InstanceRef` / `WorkspaceRef` provision or an `EffectBridge` call.
- [ ] `INST-4` Classify `AppRuntime.runPromise(...)` callsites as global,
instance-scoped with explicit refs, or bridge-required. Eliminate the
instance-scoped callsites that rely on `run-service.attach()` falling
back to `Instance.current`.
- [ ] `INST-5` After consumers are explicit, remove `Instance.current` fallback
from `InstanceState.context` and `run-service.attach()`.
- [ ] `INST-6` Move any remaining `restore` / `bind` compatibility helpers to
the boundary that still needs them, then delete
[`project/instance.ts`](../../src/project/instance.ts).
- Effect services read instance data from `InstanceRef`, `WorkspaceRef`, `InstanceState`, or explicit arguments.
- Plain JavaScript callback boundaries use `EffectBridge` or explicit context arguments.
- Runtime entrypoints must provide refs explicitly when they are instance-scoped.
## Lower Priority Tracks
+3 -5
View File
@@ -39,10 +39,9 @@ import { Filesystem } from "@/util/filesystem"
import { Hash } from "@opencode-ai/core/util/hash"
import { ACPSessionManager } from "./session"
import type { ACPConfig } from "./types"
import { ACPRuntime } from "./runtime"
import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "../provider/schema"
import { Agent as AgentModule } from "../agent/agent"
import { AppRuntime } from "@/effect/app-runtime"
import { Installation } from "@/installation"
import { MessageV2 } from "@/session/message-v2"
import { Config } from "@/config/config"
@@ -1094,7 +1093,7 @@ export class Agent implements ACPAgent {
const currentModeId = await (async () => {
if (!availableModes.length) return undefined
const defaultAgent = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo()))
const defaultAgent = await ACPRuntime.defaultAgentInfo(directory)
const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgent.name)?.id ?? availableModes[0].id
this.sessionManager.setMode(sessionId, resolvedModeId)
return resolvedModeId
@@ -1328,8 +1327,7 @@ export class Agent implements ACPAgent {
if (!current) {
this.sessionManager.setModel(session.id, model)
}
const agent =
session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo()))).name
const agent = session.modeId ?? (await ACPRuntime.defaultAgentInfo(directory)).name
const parts: Array<
| { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
+22
View File
@@ -0,0 +1,22 @@
import { Agent } from "@/agent/agent"
import { AppRuntime, type AppServices } from "@/effect/app-runtime"
import { InstanceRef } from "@/effect/instance-ref"
import { InstanceRuntime } from "@/project/instance-runtime"
import { Effect } from "effect"
// Global ACP Effect re-entry: no project InstanceRef is provided.
export const runGlobal = AppRuntime.runPromise
// Directory-scoped ACP Effect re-entry: load the project instance and provide InstanceRef.
export async function runDirectory<A, E>(input: { directory: string; effect: Effect.Effect<A, E, AppServices> }) {
const ctx = await InstanceRuntime.load({ directory: input.directory })
return AppRuntime.runPromise(input.effect.pipe(Effect.provideService(InstanceRef, ctx)))
}
export const defaultAgentInfo = (directory: string) =>
runDirectory({
directory,
effect: Agent.Service.use((svc) => svc.defaultInfo()),
})
export * as ACPRuntime from "./runtime"
+7 -13
View File
@@ -1,17 +1,11 @@
import { Instance } from "../project/instance"
import { InstanceRuntime } from "../project/instance-runtime"
import { WithInstance } from "../project/with-instance"
import { context } from "../project/instance-context"
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
return WithInstance.provide({
directory,
fn: async () => {
try {
const result = await cb()
return result
} finally {
await InstanceRuntime.disposeInstance(Instance.current)
}
},
})
const ctx = await InstanceRuntime.load({ directory })
try {
return await context.provide(ctx, cb)
} finally {
await InstanceRuntime.disposeInstance(ctx)
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ import { Permission } from "../../../permission"
import { iife } from "../../../util/iife"
import { effectCmd, fail } from "../../effect-cmd"
import { InstanceRef } from "@/effect/instance-ref"
import type { InstanceContext } from "@/project/instance"
import type { InstanceContext } from "@/project/instance-context"
export const AgentCommand = effectCmd({
command: "agent <name>",
@@ -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))
}
}
+10 -41
View File
@@ -76,8 +76,6 @@ const appBindingCommands = [
"command.palette.show",
"session.list",
"session.new",
"session.cycle_recent",
"session.cycle_recent_reverse",
"session.quick_switch.1",
"session.quick_switch.2",
"session.quick_switch.3",
@@ -481,37 +479,15 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
dialog.clear()
},
},
...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
? [
{
name: "session.cycle_recent",
title: "Cycle to previous recent session",
category: "Session",
hidden: true,
run: () => {
local.session.cycleRecent(1)
},
},
{
name: "session.cycle_recent_reverse",
title: "Cycle to next recent session",
category: "Session",
hidden: true,
run: () => {
local.session.cycleRecent(-1)
},
},
...Array.from({ length: 9 }, (_, i) => ({
name: `session.quick_switch.${i + 1}`,
title: `Switch to session in quick slot ${i + 1}`,
category: "Session",
hidden: true,
run: () => {
local.session.quickSwitch(i + 1)
},
})),
]
: []),
...Array.from({ length: 9 }, (_, i) => ({
name: `session.quick_switch.${i + 1}`,
title: `Switch to session in quick slot ${i + 1}`,
category: "Session",
hidden: true,
run: () => {
local.session.quickSwitch(i + 1)
},
})),
{
name: "model.list",
title: "Switch model",
@@ -826,14 +802,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
useBindings(() => ({
enabled: command.matcher,
bindings: tuiConfig.keybinds.gather(
"app",
Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
? appBindingCommands
: appBindingCommands.filter(
(c) => !c.startsWith("session.cycle_recent") && !c.startsWith("session.quick_switch"),
),
),
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
}))
useBindings(() => ({
@@ -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}`
}
@@ -87,9 +87,6 @@ export const Definitions = {
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
session_parent: keybind("up", "Go to parent session"),
session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"),
session_toggle_recent: keybind("ctrl+h", "Show or hide session in the Recent group"),
session_cycle_recent: keybind("<leader>]", "Cycle to the previous recent session"),
session_cycle_recent_reverse: keybind("<leader>[", "Cycle to the next recent session"),
session_quick_switch_1: keybind("<leader>1", "Switch to session in quick slot 1"),
session_quick_switch_2: keybind("<leader>2", "Switch to session in quick slot 2"),
session_quick_switch_3: keybind("<leader>3", "Switch to session in quick slot 3"),
@@ -273,9 +270,6 @@ export const CommandMap = {
session_child_cycle_reverse: "session.child.previous",
session_parent: "session.parent",
session_pin_toggle: "session.pin.toggle",
session_toggle_recent: "session.toggle.recent",
session_cycle_recent: "session.cycle_recent",
session_cycle_recent_reverse: "session.cycle_recent_reverse",
session_quick_switch_1: "session.quick_switch.1",
session_quick_switch_2: "session.quick_switch.2",
session_quick_switch_3: "session.quick_switch.3",
@@ -1,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,19 +448,10 @@ 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)
@@ -520,52 +462,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
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 })
},
}
})
@@ -0,0 +1,67 @@
import { createMemo, type Setter } from "solid-js"
import { Flag } from "@opencode-ai/core/flag/flag"
import { useKV } from "./kv"
export type ThinkingMode = "show" | "minimal" | "hide"
const MODES: readonly ThinkingMode[] = ["show", "minimal", "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 → minimal → 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", "minimal")
// 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 "minimal" default.
if (!hadStored) {
if (legacy === true) set("show")
else if (legacy === false) set("hide")
}
const mode = createMemo<ThinkingMode>(() => {
if (Flag.OPENCODE_EXPERIMENTAL_MINIMAL_THINKING) return "minimal"
const value = stored()
return isThinkingMode(value) ? value : "minimal"
})
return {
mode,
set,
locked: () => Flag.OPENCODE_EXPERIMENTAL_MINIMAL_THINKING === true,
}
}
@@ -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() === "minimal")
// 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>
<Show when={content() && thinking.mode() !== "hide"}>
<Switch>
<Match when={!inMinimal() || expanded()}>
<box
paddingLeft={2}
marginTop={1}
flexDirection="column"
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.backgroundElement}
flexShrink={0}
onMouseUp={toggle}
>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={props.subtleSyntax}
content={(inMinimal() ? "▼ " : "") + "_Thinking:_ " + content()}
conceal={true}
fg={theme.textMuted}
/>
</box>
</Match>
<Match when={isDone()}>
<box paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
<text fg={theme.textMuted} wrapMode="none">
{title() ? "▶ Thought: " + title() : "▶ Thought"}
</text>
</box>
</Match>
<Match when={true}>
<box paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
<Spinner color={theme.textMuted}>{title() ? "Thinking: " + title() : "Thinking"}</Spinner>
</box>
</Match>
</Switch>
</Show>
)
}
@@ -82,6 +82,7 @@ import * as Model from "../../util/model"
import { formatTranscript } from "../../util/transcript"
import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config"
import { nextThinkingMode, reasoningTitle, useThinkingMode, type ThinkingMode } from "../../context/thinking"
import { getScrollAcceleration } from "../../util/scroll"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { DialogRetryAction } from "../../component/dialog-retry-action"
@@ -157,6 +158,7 @@ const context = createContext<{
width: number
sessionID: string
conceal: () => boolean
thinkingMode: () => ThinkingMode
showThinking: () => boolean
showTimestamps: () => boolean
showDetails: () => boolean
@@ -214,7 +216,9 @@ export function Session() {
const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto")
const [sidebarOpen, setSidebarOpen] = createSignal(false)
const [conceal, setConceal] = createSignal(true)
const [showThinking, setShowThinking] = kv.signal("thinking_visibility", true)
const thinking = useThinkingMode()
const thinkingMode = thinking.mode
const showThinking = createMemo(() => thinkingMode() !== "hide")
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
const [showAssistantMetadata, _setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
@@ -683,7 +687,12 @@ export function Session() {
},
},
{
title: showThinking() ? "Hide thinking" : "Show thinking",
title: (() => {
const next = nextThinkingMode(thinkingMode())
if (next === "minimal") return "Switch thinking to minimal"
if (next === "hide") return "Hide thinking"
return "Show thinking"
})(),
value: "session.toggle.thinking",
category: "Session",
slash: {
@@ -691,7 +700,17 @@ export function Session() {
aliases: ["toggle-thinking"],
},
run: () => {
setShowThinking((prev) => !prev)
// Env override forces minimal for the process. Updating KV here would
// silently diverge from what's rendered; tell the user instead.
if (thinking.locked()) {
toast.show({
message: "Thinking mode is locked to minimal by OPENCODE_EXPERIMENTAL_MINIMAL_THINKING",
variant: "info",
})
dialog.clear()
return
}
thinking.set(nextThinkingMode(thinkingMode()))
dialog.clear()
},
},
@@ -1086,6 +1105,7 @@ export function Session() {
},
sessionID: route.sessionID,
conceal,
thinkingMode,
showThinking,
showTimestamps,
showDetails,
@@ -1492,32 +1512,77 @@ const PART_MAPPING = {
function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
const { theme, subtleSyntax } = useTheme()
const ctx = use()
// Collapsed by default in minimal mode: a single line throughout, so the
// layout never shifts. Click to open the full markdown block, click to close.
const [expanded, setExpanded] = createSignal(false)
const content = createMemo(() => {
// Filter out redacted reasoning chunks from OpenRouter
// OpenRouter sends encrypted reasoning data that appears as [REDACTED]
// OpenRouter encrypts some reasoning blocks; drop the placeholder.
return props.part.text.replace("[REDACTED]", "").trim()
})
// Reasoning is finalized when the server sets `time.end` (see processor.ts).
// Flips independently of the parent message completing.
const isDone = createMemo(() => props.part.time.end !== undefined)
const inMinimal = createMemo(() => ctx.thinkingMode() === "minimal")
const duration = createMemo(() => {
const end = props.part.time.end
return end === undefined ? 0 : Math.max(0, end - props.part.time.start)
})
// OpenAI / Copilot / opencode-via-OpenAI emit `**Title**\n\n<body>` summary
// blocks. Surface the title both while streaming and after settling so the
// collapsed line carries real signal, not just a duration.
const title = createMemo(() => reasoningTitle(content()))
const toggle = () => {
if (!inMinimal()) return
setExpanded((prev) => !prev)
}
return (
<Show when={content() && ctx.showThinking()}>
<box
id={"text-" + props.part.id}
paddingLeft={2}
marginTop={1}
flexDirection="column"
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.backgroundElement}
>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={subtleSyntax()}
content={"_Thinking:_ " + content()}
conceal={ctx.conceal()}
fg={theme.textMuted}
/>
</box>
<Show when={content() && ctx.thinkingMode() !== "hide"}>
<Switch>
<Match when={!inMinimal() || expanded()}>
{/* Full markdown block: `show` mode, or `minimal` after the user opens it. */}
<box
id={"text-" + props.part.id}
paddingLeft={2}
marginTop={1}
flexDirection="column"
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.backgroundElement}
onMouseUp={toggle}
>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={subtleSyntax()}
content={(inMinimal() ? "▼ " : "") + "_Thinking:_ " + content()}
conceal={ctx.conceal()}
fg={theme.textMuted}
/>
</box>
</Match>
<Match when={isDone()}>
{/* Settled: ▶ at the start as the click-to-expand cue. */}
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
<text fg={theme.textMuted} wrapMode="none">
{"▶ " +
(title()
? "Thought: " + title() + " · " + Locale.duration(duration())
: "Thought for " + Locale.duration(duration()))}
</text>
</box>
</Match>
<Match when={true}>
{/* Streaming: leading animated spinner, no disclosure arrow yet — it
snaps in once reasoning settles, signalling "done, click to expand". */}
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
<Spinner color={theme.textMuted}>{title() ? "Thinking: " + title() : "Thinking"}</Spinner>
</box>
</Match>
</Switch>
</Show>
)
}
@@ -1533,6 +1598,7 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
streaming={true}
internalBlockMode="top-level"
content={props.part.text.trim()}
tableOptions={{ style: "grid" }}
conceal={ctx.conceal()}
fg={theme.markdownText}
bg={theme.background}
@@ -38,6 +38,11 @@ export interface DialogSelectProps<T> {
disabled?: boolean
onTrigger: (option: DialogSelectOption<T>) => void
}[]
footerHints?: {
title: string
label: string
side?: "left" | "right"
}[]
bindings?: readonly Binding<Renderable, KeyEvent>[]
current?: T
}
@@ -334,11 +339,12 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}
props.ref?.(ref)
const visibleActions = createMemo(() =>
actions()
const visibleActions = createMemo(() => [
...actions()
.map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" }))
.filter((item) => !item.disabled && item.label),
)
...(props.footerHints ?? []),
])
const left = createMemo(() => visibleActions().filter((item) => item.side !== "right"))
const right = createMemo(() => visibleActions().filter((item) => item.side === "right"))
+2 -7
View File
@@ -2,7 +2,6 @@ import { Installation } from "@/installation"
import { Server } from "@/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { InstanceRuntime } from "@/project/instance-runtime"
import { WithInstance } from "@/project/with-instance"
import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
@@ -77,12 +76,8 @@ export const rpc = {
return { url: server.url.toString() }
},
async checkUpgrade(input: { directory: string }) {
await WithInstance.provide({
directory: input.directory,
fn: async () => {
await upgrade().catch(() => {})
},
})
await InstanceRuntime.load({ directory: input.directory })
await upgrade().catch(() => {})
},
async reload() {
await AppRuntime.runPromise(
+1 -10
View File
@@ -3,7 +3,6 @@ import { Effect, Schema } from "effect"
import { AppRuntime, type AppServices } from "@/effect/app-runtime"
import { InstanceStore } from "@/project/instance-store"
import { InstanceRef } from "@/effect/instance-ref"
import { Instance } from "@/project/instance"
import { cmd, type WithDoubleDash } from "./cmd/cmd"
/**
@@ -83,19 +82,11 @@ export const effectCmd = <Args, A>(opts: EffectCmdOpts<Args, A>) =>
return
}
const directory = opts.directory?.(args) ?? process.cwd()
// Two-phase: load ctx, then run body inside Instance.current ALS.
// Effect's InstanceRef is provided via fiber context, but that context is
// lost across `await` inside `Effect.promise(async () => ...)` callbacks
// — when handlers re-enter Effect via `AppRuntime.runPromise(svc.method())`
// there, attach() falls back to Instance.current ALS, which Node preserves
// across awaits. Matches the pre-effectCmd `bootstrap()` behavior.
const { store, ctx } = await AppRuntime.runPromise(
InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))),
)
try {
await Instance.restore(ctx, () =>
AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx))),
)
await AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx)))
} finally {
await AppRuntime.runPromise(store.dispose(ctx))
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { EffectBridge } from "@/effect/bridge"
import type { InstanceContext } from "@/project/instance"
import type { InstanceContext } from "@/project/instance-context"
import { SessionID, MessageID } from "@/session/schema"
import { Effect, Layer, Context, Schema } from "effect"
import { Config } from "@/config/config"
+1 -2
View File
@@ -10,7 +10,6 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { Auth } from "../auth"
import { Env } from "../env"
import { applyEdits, modify } from "jsonc-parser"
import { type InstanceContext } from "../project/instance"
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
import { existsSync } from "fs"
import { Account } from "@/account/account"
@@ -20,7 +19,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { InstanceState } from "@/effect/instance-state"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { containsPath } from "../project/instance-context"
import { containsPath, type InstanceContext } from "../project/instance-context"
import { NonNegativeInt, PositiveInt, type DeepMutable } from "@opencode-ai/core/schema"
import { ConfigAgent } from "./agent"
import { ConfigAttachment } from "./attachment"
@@ -1,5 +1,6 @@
import { Schema } from "effect"
import { type WorkspaceAdapter, WorkspaceInfo } from "../types"
import { Effect, Schema } from "effect"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { type WorkspaceAdapter, type WorkspaceAdapterContext, WorkspaceInfo } from "../types"
const WorktreeConfig = Schema.Struct({
name: WorkspaceInfo.fields.name,
@@ -9,53 +10,81 @@ const WorktreeConfig = Schema.Struct({
const decodeWorktreeConfig = Schema.decodeUnknownSync(WorktreeConfig)
async function loadWorktree() {
const [{ AppRuntime }, { Instance }, { Worktree }] = await Promise.all([
import("@/effect/app-runtime"),
import("@/project/instance"),
import("@/worktree"),
])
return { AppRuntime, Instance, Worktree }
const [{ AppRuntime }, { Worktree }] = await Promise.all([import("@/effect/app-runtime"), import("@/worktree")])
return { AppRuntime, Worktree }
}
function requireInstance(context: WorkspaceAdapterContext | undefined) {
if (!context?.instance) throw new Error("Worktree adapter requires an instance context")
return context.instance
}
const provideContext = <A, E, R>(effect: Effect.Effect<A, E, R>, context: WorkspaceAdapterContext | undefined) =>
effect.pipe(
Effect.provideService(InstanceRef, requireInstance(context)),
Effect.provideService(WorkspaceRef, context?.workspaceID),
)
export const WorktreeAdapter: WorkspaceAdapter = {
name: "Worktree",
description: "Create a git worktree",
async configure(info) {
async configure(info, context) {
const { AppRuntime, Worktree } = await loadWorktree()
const next = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo({ detached: true })))
const next = await AppRuntime.runPromise(
provideContext(
Worktree.Service.use((svc) => svc.makeWorktreeInfo({ detached: true })),
context,
),
)
return {
...info,
name: next.name,
directory: next.directory,
}
},
async create(info) {
async create(info, _env, _from, context) {
const { AppRuntime, Worktree } = await loadWorktree()
const config = decodeWorktreeConfig(info)
await AppRuntime.runPromise(
Worktree.Service.use((svc) =>
svc.createFromInfo({
name: config.name,
directory: config.directory,
...(config.branch ? { branch: config.branch } : {}),
}),
provideContext(
Worktree.Service.use((svc) =>
svc.createFromInfo({
name: config.name,
directory: config.directory,
...(config.branch ? { branch: config.branch } : {}),
}),
),
context,
),
)
},
async list() {
const { AppRuntime, Instance, Worktree } = await loadWorktree()
return (await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.list()))).map((info) => ({
async list(context) {
const { AppRuntime, Worktree } = await loadWorktree()
const ctx = requireInstance(context)
return (
await AppRuntime.runPromise(
provideContext(
Worktree.Service.use((svc) => svc.list()),
context,
),
)
).map((info) => ({
type: "worktree",
name: info.name,
branch: info.branch,
directory: info.directory,
projectID: Instance.project.id,
projectID: ctx.project.id,
}))
},
async remove(info) {
async remove(info, context) {
const { AppRuntime, Worktree } = await loadWorktree()
const config = decodeWorktreeConfig(info)
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove({ directory: config.directory })))
await AppRuntime.runPromise(
provideContext(
Worktree.Service.use((svc) => svc.remove({ directory: config.directory })),
context,
),
)
},
target(info) {
const config = decodeWorktreeConfig(info)
+16 -5
View File
@@ -1,5 +1,6 @@
import { Schema, Struct } from "effect"
import { ProjectID } from "@/project/schema"
import type { InstanceContext } from "@/project/instance-context"
import { WorkspaceID } from "./schema"
import type { DeepMutable } from "@opencode-ai/core/schema"
@@ -37,12 +38,22 @@ export type Target =
headers?: HeadersInit
}
export type WorkspaceAdapterContext = {
readonly instance?: InstanceContext
readonly workspaceID?: WorkspaceID
}
export type WorkspaceAdapter = {
name: string
description: string
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
create(info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo): Promise<void>
list?(): WorkspaceListedInfo[] | Promise<WorkspaceListedInfo[]>
remove(info: WorkspaceInfo): Promise<void>
target(info: WorkspaceInfo): Target | Promise<Target>
configure(info: WorkspaceInfo, context?: WorkspaceAdapterContext): WorkspaceInfo | Promise<WorkspaceInfo>
create(
info: WorkspaceInfo,
env: Record<string, string | undefined>,
from?: WorkspaceInfo,
context?: WorkspaceAdapterContext,
): Promise<void>
list?(context?: WorkspaceAdapterContext): WorkspaceListedInfo[] | Promise<WorkspaceListedInfo[]>
remove(info: WorkspaceInfo, context?: WorkspaceAdapterContext): Promise<void>
target(info: WorkspaceInfo, context?: WorkspaceAdapterContext): Target | Promise<Target>
}
@@ -0,0 +1,51 @@
import { Effect } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { getAdapter } from "./adapters"
import type { WorkspaceAdapter, WorkspaceInfo } from "./types"
const context = Effect.gen(function* () {
return {
instance: yield* InstanceRef,
workspaceID: yield* WorkspaceRef,
}
})
export const target = (info: WorkspaceInfo) =>
Effect.gen(function* () {
const adapter = getAdapter(info.projectID, info.type)
const ctx = yield* context
return yield* EffectBridge.fromPromise(() => adapter.target(info, ctx))
})
export const configure = (adapter: WorkspaceAdapter, info: WorkspaceInfo) =>
Effect.gen(function* () {
const ctx = yield* context
return yield* EffectBridge.fromPromise(() => adapter.configure(info, ctx))
})
export const create = (
adapter: WorkspaceAdapter,
info: WorkspaceInfo,
env: Record<string, string | undefined>,
from?: WorkspaceInfo,
) =>
Effect.gen(function* () {
const ctx = yield* context
return yield* EffectBridge.fromPromise(() => adapter.create(info, env, from, ctx))
})
export const list = (adapter: WorkspaceAdapter) =>
Effect.gen(function* () {
const ctx = yield* context
return yield* EffectBridge.fromPromise(() => Promise.resolve(adapter.list?.(ctx) ?? []))
})
export const remove = (info: WorkspaceInfo) =>
Effect.gen(function* () {
const adapter = getAdapter(info.projectID, info.type)
const ctx = yield* context
return yield* EffectBridge.fromPromise(() => adapter.remove(info, ctx))
})
export * as WorkspaceAdapterRuntime from "./workspace-adapter-runtime"
@@ -26,11 +26,11 @@ import { SessionID } from "@/session/schema"
import { NotFoundError } from "@/storage/storage"
import { errorData } from "@/util/error"
import { waitEvent } from "./util"
import { WorkspaceContext } from "./workspace-context"
import { EffectBridge } from "@/effect/bridge"
import { WorkspaceRef } from "@/effect/instance-ref"
import { Vcs } from "@/project/vcs"
import { InstanceStore } from "@/project/instance-store"
import { InstanceBootstrap } from "@/project/bootstrap"
import { WorkspaceAdapterRuntime } from "./workspace-adapter-runtime"
export const Info = Schema.Struct({
...WorkspaceInfoSchema.fields,
@@ -281,8 +281,7 @@ export const layer = Layer.effect(
const workspace = yield* get(input.workspaceID)
if (!workspace) return input.fallback
const adapter = getAdapter(workspace.projectID, workspace.type)
const target = yield* EffectBridge.fromPromise(() => adapter.target(workspace))
const target = yield* WorkspaceAdapterRuntime.target(workspace)
if (target.type === "local") {
const store = yield* InstanceStore.Service
@@ -375,35 +374,27 @@ export const layer = Layer.effect(
events: events.length,
})
yield* Effect.promise(async () => {
await WorkspaceContext.provide({
workspaceID: space.id,
async fn() {
await Effect.runPromise(
Effect.forEach(
events,
(event) =>
sync.replay(
{
id: event.id,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
},
{ publish: true },
),
{ discard: true },
),
yield* Effect.forEach(
events,
(event) =>
sync
.replay(
{
id: event.id,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
},
{ publish: true },
)
},
})
})
.pipe(Effect.provideService(WorkspaceRef, space.id)),
{ discard: true },
)
})
const syncWorkspaceLoop = Effect.fn("Workspace.syncWorkspaceLoop")(function* (space: Info) {
const adapter = getAdapter(space.projectID, space.type)
const target = yield* EffectBridge.fromPromise(() => adapter.target(space))
const target = yield* WorkspaceAdapterRuntime.target(space)
if (target.type === "local") return
@@ -486,8 +477,7 @@ export const layer = Layer.effect(
const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) {
if (!flags.experimentalWorkspaces) return
const adapter = getAdapter(space.projectID, space.type)
const target = yield* EffectBridge.fromPromise(() => adapter.target(space)).pipe(
const target = yield* WorkspaceAdapterRuntime.target(space).pipe(
Effect.catch((error) =>
Effect.sync(() => {
setStatus(space.id, "error")
@@ -538,15 +528,13 @@ export const layer = Layer.effect(
const create = Effect.fn("Workspace.create")(function* (input: CreateInput) {
const id = WorkspaceID.ascending(input.id)
const adapter = getAdapter(input.projectID, input.type)
const config = yield* EffectBridge.fromPromise(() =>
adapter.configure({
...input,
id,
name: Slug.create(),
directory: null,
extra: input.extra ?? null,
}),
)
const config = yield* WorkspaceAdapterRuntime.configure(adapter, {
...input,
id,
name: Slug.create(),
directory: null,
extra: input.extra ?? null,
})
const info: Info = {
id,
@@ -583,7 +571,7 @@ export const layer = Layer.effect(
OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES,
}
yield* EffectBridge.fromPromise(() => adapter.create(config, env))
yield* WorkspaceAdapterRuntime.create(adapter, config, env)
yield* Effect.all(
[
waitEvent({
@@ -622,8 +610,7 @@ export const layer = Layer.effect(
if (current?.workspaceID) {
const previous = yield* get(current.workspaceID)
if (previous) {
const adapter = getAdapter(previous.projectID, previous.type)
const target = yield* EffectBridge.fromPromise(() => adapter.target(previous))
const target = yield* WorkspaceAdapterRuntime.target(previous)
if (target.type === "remote") {
yield* syncHistory(previous, target.url, target.headers).pipe(
@@ -701,8 +688,7 @@ export const layer = Layer.effect(
workspaceID,
})
const adapter = getAdapter(space.projectID, space.type)
const target = yield* EffectBridge.fromPromise(() => adapter.target(space))
const target = yield* WorkspaceAdapterRuntime.target(space)
if (target.type === "local") {
yield* sync.run(Session.Event.Updated, {
@@ -855,16 +841,14 @@ export const layer = Layer.effect(
const discovered = yield* Effect.forEach(
registeredAdapters(project.id),
([type, adapter]) =>
adapter.list
? EffectBridge.fromPromise(() => Promise.resolve(adapter.list?.() ?? [])).pipe(
Effect.catchCause((error) =>
Effect.sync(() => {
log.warn("workspace adapter list failed", { type, error })
return []
}),
),
)
: Effect.succeed([]),
WorkspaceAdapterRuntime.list(adapter).pipe(
Effect.catchCause((error) =>
Effect.sync(() => {
log.warn("workspace adapter list failed", { type, error })
return []
}),
),
),
{ concurrency: "unbounded" },
).pipe(Effect.map((items) => items.flat()))
@@ -937,8 +921,7 @@ export const layer = Layer.effect(
const info = fromRow(row)
yield* Effect.catchCause(
Effect.gen(function* () {
const adapter = getAdapter(info.projectID, row.type)
yield* EffectBridge.fromPromise(() => adapter.remove(info))
yield* WorkspaceAdapterRuntime.remove(info)
}),
() =>
Effect.sync(() => {
+41 -35
View File
@@ -1,8 +1,6 @@
import { Effect, Exit, Fiber } from "effect"
import { Context, Effect, Exit, Fiber } from "effect"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { Instance, type InstanceContext } from "@/project/instance"
import type { WorkspaceID } from "@/control-plane/schema"
import { LocalContext } from "@/util/local-context"
import { InstanceRef, WorkspaceRef } from "./instance-ref"
import { attachWith } from "./run-service"
@@ -10,67 +8,75 @@ export interface Shape {
readonly promise: <A, E, R>(effect: Effect.Effect<A, E, R>) => Promise<A>
readonly fork: <A, E, R>(effect: Effect.Effect<A, E, R>) => Fiber.Fiber<A, E>
readonly run: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E>
readonly bind: <Args extends readonly unknown[], Result>(fn: (...args: Args) => Result) => (...args: Args) => Result
}
function restore<R>(instance: InstanceContext | undefined, workspace: WorkspaceID | undefined, fn: () => R): R {
if (instance && workspace !== undefined) {
return WorkspaceContext.restore(workspace, () => Instance.restore(instance, fn))
}
if (instance) return Instance.restore(instance, fn)
function restoreWorkspace<R>(workspace: WorkspaceID | undefined, fn: () => R): R {
if (workspace !== undefined) return WorkspaceContext.restore(workspace, fn)
return fn()
}
function captureSync() {
const fiber = Fiber.getCurrent()
const instance = fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined
const workspace =
(fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined) ?? WorkspaceContext.workspaceID
return { instance, workspace }
}
export const bind = <Args extends readonly unknown[], Result>(fn: (...args: Args) => Result) => {
const captured = captureSync()
return (...args: Args) =>
restoreWorkspace(captured.workspace, () =>
Effect.runSync(
attachWith(
Effect.sync(() => fn(...args)),
captured,
),
),
)
}
/**
* Bridge from Effect into a Promise-returning JS callback while installing
* legacy `Instance.context` and `WorkspaceContext` AsyncLocalStorage for
* the duration of the callback. Effect's `InstanceRef`/`WorkspaceRef` do
* not propagate across async/await boundaries inside `Effect.promise(() =>
* async fn)` callbacks that re-enter Effect via `AppRuntime.runPromise`,
* but Node's AsyncLocalStorage does. Use this whenever an Effect crosses
* into JS that may itself spawn new Effect runtimes (workspace adapters,
* legacy plugins, etc.).
* Bridge from Effect into a Promise-returning JS callback while preserving
* `WorkspaceContext` AsyncLocalStorage for callback code that still reads it.
* `InstanceRef` is captured for effects run through the returned bridge APIs;
* plain JS callbacks that need it should receive the ref explicitly.
*
* Mirrors `Effect.promise` but restores legacy ALS first.
* Mirrors `Effect.promise` but restores workspace ALS first.
*/
export const fromPromise = <T>(fn: () => Promise<T> | T): Effect.Effect<T> =>
Effect.gen(function* () {
const instance = yield* InstanceRef
const workspace = yield* WorkspaceRef
return yield* Effect.promise(() => Promise.resolve(restore(instance, workspace, () => fn())))
return yield* Effect.promise(() => Promise.resolve(restoreWorkspace(workspace, () => fn())))
})
export function make(): Effect.Effect<Shape> {
return Effect.gen(function* () {
const ctx = yield* Effect.context()
const value = yield* InstanceRef
const instance =
value ??
(() => {
try {
return Instance.current
} catch (err) {
if (!(err instanceof LocalContext.NotFound)) throw err
}
})()
const workspace = (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID
const attach = <A, E, R>(effect: Effect.Effect<A, E, R>) => attachWith(effect, { instance, workspace })
const captured = captureSync()
const instance = (yield* InstanceRef) ?? captured.instance
const workspace = (yield* WorkspaceRef) ?? captured.workspace
const wrap = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
attach(effect).pipe(Effect.provide(ctx)) as Effect.Effect<A, E, never>
attachWith(effect.pipe(Effect.provide(ctx)) as Effect.Effect<A, E, never>, { instance, workspace })
return {
promise: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
restore(instance, workspace, () => Effect.runPromise(wrap(effect))),
restoreWorkspace(workspace, () => Effect.runPromise(wrap(effect))),
fork: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
restore(instance, workspace, () => Effect.runFork(wrap(effect))),
restoreWorkspace(workspace, () => Effect.runFork(wrap(effect))),
run: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.callback<A, E>((resume) => {
restore(instance, workspace, () =>
restoreWorkspace(workspace, () =>
Effect.runPromiseExit(wrap(effect)).then((exit) =>
resume(Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause)),
),
)
}),
bind:
<Args extends readonly unknown[], Result>(fn: (...args: Args) => Result) =>
(...args: Args) =>
restoreWorkspace(workspace, () => Effect.runSync(wrap(Effect.sync(() => fn(...args))))),
} satisfies Shape
})
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { Context } from "effect"
import type { InstanceContext } from "@/project/instance"
import type { InstanceContext } from "@/project/instance-context"
import type { WorkspaceID } from "@/control-plane/schema"
export const InstanceRef = Context.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
+5 -16
View File
@@ -1,7 +1,6 @@
import { Effect, Fiber, ScopedCache, Scope, Context } from "effect"
import { Effect, ScopedCache, Scope } from "effect"
import * as EffectLogger from "@opencode-ai/core/effect/logger"
import { Instance, type InstanceContext } from "@/project/instance"
import { LocalContext } from "@/util/local-context"
import type { InstanceContext } from "@/project/instance-context"
import { InstanceRef, WorkspaceRef } from "./instance-ref"
import { registerDisposer } from "./instance-registry"
import { WorkspaceContext } from "@/control-plane/workspace-context"
@@ -13,20 +12,10 @@ export interface InstanceState<A, E = never, R = never> {
readonly cache: ScopedCache.ScopedCache<string, A, E, R>
}
export const bind = <F extends (...args: any[]) => any>(fn: F): F => {
try {
return Instance.bind(fn)
} catch (err) {
if (!(err instanceof LocalContext.NotFound)) throw err
}
const fiber = Fiber.getCurrent()
const ctx = fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined
if (!ctx) return fn
return ((...args: any[]) => Instance.restore(ctx, () => fn(...args))) as F
}
export const context = Effect.gen(function* () {
return (yield* InstanceRef) ?? Instance.current
const ctx = yield* InstanceRef
if (!ctx) return yield* Effect.die(new Error("InstanceRef not provided"))
return ctx
})
export const workspaceID = Effect.gen(function* () {
+2 -12
View File
@@ -1,11 +1,9 @@
import { Effect, Fiber, Layer, ManagedRuntime } from "effect"
import * as Context from "effect/Context"
import { Instance } from "@/project/instance"
import { LocalContext } from "@/util/local-context"
import { InstanceRef, WorkspaceRef } from "./instance-ref"
import * as Observability from "@opencode-ai/core/effect/observability"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import type { InstanceContext } from "@/project/instance"
import type { InstanceContext } from "@/project/instance-context"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
type Refs = {
@@ -25,17 +23,9 @@ export function attachWith<A, E, R>(effect: Effect.Effect<A, E, R>, refs: Refs):
export function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
const workspace = WorkspaceContext.workspaceID
const instance = (() => {
try {
return Instance.current
} catch (err) {
if (!(err instanceof LocalContext.NotFound)) throw err
}
})()
if (instance && workspace !== undefined) return attachWith(effect, { instance, workspace })
const fiber = Fiber.getCurrent()
return attachWith(effect, {
instance: instance ?? (fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined),
instance: fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined,
workspace: workspace ?? (fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined),
})
}
@@ -17,6 +17,13 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
disableDefaultPlugins: bool("OPENCODE_DISABLE_DEFAULT_PLUGINS"),
disableChannelDb: bool("OPENCODE_DISABLE_CHANNEL_DB"),
disableEmbeddedWebUi: bool("OPENCODE_DISABLE_EMBEDDED_WEB_UI"),
disableExternalSkills: bool("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
disableLspDownload: bool("OPENCODE_DISABLE_LSP_DOWNLOAD"),
skipMigrations: bool("OPENCODE_SKIP_MIGRATIONS"),
disableClaudeCodePrompt: Config.all({
broad: bool("OPENCODE_DISABLE_CLAUDE_CODE"),
direct: bool("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
}).pipe(Config.map((flags) => flags.broad || flags.direct)),
disableClaudeCodeSkills: Config.all({
broad: bool("OPENCODE_DISABLE_CLAUDE_CODE"),
direct: bool("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS"),
@@ -41,6 +48,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
}) {}
+12 -5
View File
@@ -2,10 +2,11 @@ import { Cause, Effect, Layer, Context, Schema } from "effect"
// @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper"
import type ParcelWatcher from "@parcel/watcher"
import { readdir } from "fs/promises"
import { readdir, realpath } from "fs/promises"
import path from "path"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Git } from "@/git"
@@ -88,13 +89,13 @@ export const layer = Layer.effect(
if (!w) return
log.info("watcher backend", { directory: ctx.directory, platform: process.platform, backend })
const bridge = yield* EffectBridge.make()
const subs: ParcelWatcher.AsyncSubscription[] = []
yield* Effect.addFinalizer(() =>
Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
)
const cb: ParcelWatcher.SubscribeCallback = InstanceState.bind((err, evts) => {
const cb: ParcelWatcher.SubscribeCallback = bridge.bind((err, evts) => {
if (err) return
for (const evt of evts) {
if (evt.type === "create") void Bus.publish(Event.Updated, { file: evt.path, event: "add" })
@@ -131,8 +132,14 @@ export const layer = Layer.effect(
const result = yield* git.run(["rev-parse", "--git-dir"], {
cwd: ctx.worktree,
})
const vcsDir = result.exitCode === 0 ? path.resolve(ctx.worktree, result.text().trim()) : undefined
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
const resolved = result.exitCode === 0 ? path.resolve(ctx.worktree, result.text().trim()) : undefined
const vcsDir = resolved ? yield* Effect.promise(() => realpath(resolved).catch(() => resolved)) : undefined
if (
vcsDir &&
!cfgIgnores.includes(".git") &&
!cfgIgnores.includes(vcsDir) &&
(!resolved || !cfgIgnores.includes(resolved))
) {
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
(entry) => entry !== "HEAD",
)
+1 -1
View File
@@ -1,5 +1,5 @@
import { Npm } from "@opencode-ai/core/npm"
import type { InstanceContext } from "../project/instance"
import type { InstanceContext } from "../project/instance-context"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { which } from "../util/which"
+26 -7
View File
@@ -7,10 +7,13 @@ import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types
import * as Log from "@opencode-ai/core/util/log"
import { Process } from "@/util/process"
import { LANGUAGE_EXTENSIONS } from "./language"
import { Schema } from "effect"
import { Effect, Schema } from "effect"
import type * as LSPServer from "./server"
import { withTimeout } from "../util/timeout"
import { Filesystem } from "@/util/filesystem"
import { InstanceRef } from "@/effect/instance-ref"
import { makeRuntime } from "@/effect/run-service"
import type { InstanceContext } from "@/project/instance-context"
const DIAGNOSTICS_DEBOUNCE_MS = 150
const DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS = 5_000
@@ -25,6 +28,7 @@ const FILE_CHANGE_CHANGED = 2
const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2
const log = Log.create({ service: "lsp.client" })
const busRuntime = makeRuntime(Bus.Service, Bus.layer)
export type Info = NonNullable<Awaited<ReturnType<typeof create>>>
@@ -134,9 +138,16 @@ function shouldSeedDiagnosticsOnFirstPush(serverID: string) {
return serverID === "typescript"
}
export async function create(input: { serverID: string; server: LSPServer.Handle; root: string; directory: string }) {
export async function create(input: {
serverID: string
server: LSPServer.Handle
root: string
directory: string
instance: InstanceContext
}) {
const logger = log.clone().tag("serverID", input.serverID)
logger.info("starting client")
const instance = input.instance
const connection = createMessageConnection(
new StreamMessageReader(input.server.process.stdout as any),
@@ -162,7 +173,11 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
dedupeDiagnostics([...(pushDiagnostics.get(filePath) ?? []), ...(pullDiagnostics.get(filePath) ?? [])])
const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => {
pushDiagnostics.set(filePath, next)
Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })
void busRuntime.runPromise((svc) =>
svc
.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })
.pipe(Effect.provideService(InstanceRef, instance)),
)
}
const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => {
pullDiagnostics.set(filePath, next)
@@ -510,10 +525,14 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
}
timeoutTimer = setTimeout(() => finish(false), request.timeout)
unsub = Bus.subscribe(Event.Diagnostics, (event) => {
if (event.properties.path !== request.path || event.properties.serverID !== input.serverID) return
schedule()
})
unsub = busRuntime.runSync((svc) =>
svc
.subscribeCallback(Event.Diagnostics, (event) => {
if (event.properties.path !== request.path || event.properties.serverID !== input.serverID) return
schedule()
})
.pipe(Effect.provideService(InstanceRef, instance)),
)
schedule()
})
}
+1
View File
@@ -237,6 +237,7 @@ export const layer = Layer.effect(
server: handle,
root,
directory: ctx.directory,
instance: ctx,
}).catch(async (err) => {
s.broken.add(key)
await Process.stop(handle.process)
+51 -52
View File
@@ -6,8 +6,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { text } from "node:stream/consumers"
import fs from "fs/promises"
import { Filesystem } from "@/util/filesystem"
import type { InstanceContext } from "../project/instance"
import { Flag } from "@opencode-ai/core/flag/flag"
import type { InstanceContext } from "../project/instance-context"
import { Archive } from "@/util/archive"
import { Process } from "@/util/process"
import { which } from "../util/which"
@@ -126,11 +125,11 @@ export const Vue: Info = {
id: "vue",
extensions: [".vue"],
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
async spawn(root) {
async spawn(root, _ctx, flags) {
let binary = which("vue-language-server")
const args: string[] = []
if (!binary) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
if (flags.disableLspDownload) return
const resolved = await Npm.which("@vue/language-server")
if (!resolved) return
binary = resolved
@@ -155,13 +154,13 @@ export const ESLint: Info = {
id: "eslint",
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"],
async spawn(root, ctx) {
async spawn(root, ctx, flags) {
const eslint = Module.resolve("eslint", ctx.directory)
if (!eslint) return
log.info("spawning eslint server")
const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js")
if (!(await Filesystem.exists(serverPath))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
if (flags.disableLspDownload) return
log.info("downloading and building VS Code ESLint server")
const response = await fetch("https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip")
if (!response.ok) return
@@ -351,11 +350,11 @@ export const Gopls: Info = {
return NearestRoot(["go.mod", "go.sum"])(file, ctx)
},
extensions: [".go"],
async spawn(root) {
async spawn(root, _ctx, flags) {
let bin = which("gopls")
if (!bin) {
if (!which("go")) return
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
if (flags.disableLspDownload) return
log.info("installing gopls")
const proc = Process.spawn(["go", "install", "golang.org/x/tools/gopls@latest"], {
@@ -386,7 +385,7 @@ export const Rubocop: Info = {
id: "ruby-lsp",
root: NearestRoot(["Gemfile"]),
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async spawn(root) {
async spawn(root, _ctx, flags) {
let bin = which("rubocop")
if (!bin) {
const ruby = which("ruby")
@@ -395,7 +394,7 @@ export const Rubocop: Info = {
log.info("Ruby not found, please install Ruby first")
return
}
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
if (flags.disableLspDownload) return
log.info("installing rubocop")
const proc = Process.spawn(["gem", "install", "rubocop", "--bindir", Global.Path.bin], {
stdout: "pipe",
@@ -486,11 +485,11 @@ export const Pyright: Info = {
id: "pyright",
extensions: [".py", ".pyi"],
root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]),
async spawn(root) {
async spawn(root, _ctx, flags) {
let binary = which("pyright-langserver")
const args = []
if (!binary) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
if (flags.disableLspDownload) return
const resolved = await Npm.which("pyright", "pyright-langserver")
if (!resolved) return
binary = resolved
@@ -530,7 +529,7 @@ export const ElixirLS: Info = {
id: "elixir-ls",
extensions: [".ex", ".exs"],
root: NearestRoot(["mix.exs", "mix.lock"]),
async spawn(root) {
async spawn(root, _ctx, flags) {
let binary = which("elixir-ls")
if (!binary) {
const elixirLsPath = path.join(Global.Path.bin, "elixir-ls")
@@ -548,7 +547,7 @@ export const ElixirLS: Info = {
return
}
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
if (flags.disableLspDownload) return
log.info("downloading elixir-ls from GitHub releases")
const response = await fetch("https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip")
@@ -593,7 +592,7 @@ export const Zls: Info = {
id: "zls",
extensions: [".zig", ".zon"],
root: NearestRoot(["build.zig"]),
async spawn(root) {
async spawn(root, _ctx, flags) {
let bin = which("zls")
if (!bin) {
@@ -603,7 +602,7 @@ export const Zls: Info = {
return
}
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
if (flags.disableLspDownload) return
log.info("downloading zls from GitHub releases")
const releaseResponse = await fetch("https://api.github.com/repos/zigtools/zls/releases/latest")
@@ -705,8 +704,8 @@ export const CSharp: Info = {
id: "csharp",
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
extensions: [".cs", ".csx"],
async spawn(root) {
const bin = await getRoslynLanguageServer()
async spawn(root, _ctx, flags) {
const bin = await getRoslynLanguageServer(flags.disableLspDownload)
if (!bin) return
return {
@@ -721,8 +720,8 @@ export const Razor: Info = {
id: "razor",
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
extensions: [".razor", ".cshtml"],
async spawn(root) {
const bin = await getRoslynLanguageServer()
async spawn(root, _ctx, flags) {
const bin = await getRoslynLanguageServer(flags.disableLspDownload)
if (!bin) return
const razor = await findVscodeRazorExtension()
@@ -753,26 +752,26 @@ export const Razor: Info = {
let roslynLanguageServerInstall: Promise<string | undefined> | undefined
async function getRoslynLanguageServer() {
async function getRoslynLanguageServer(disableLspDownload: boolean) {
const existing = which("roslyn-language-server")
if (existing) return existing
const global = await roslynLanguageServerGlobalPath()
if (global) return global
roslynLanguageServerInstall ||= installRoslynLanguageServer().finally(() => {
roslynLanguageServerInstall ||= installRoslynLanguageServer(disableLspDownload).finally(() => {
roslynLanguageServerInstall = undefined
})
return roslynLanguageServerInstall
}
async function installRoslynLanguageServer() {
async function installRoslynLanguageServer(disableLspDownload: boolean) {
if (!which("dotnet")) {
log.error(".NET SDK is required to install roslyn-language-server")
return
}
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
if (disableLspDownload) return
log.info("installing roslyn-language-server via dotnet tool")
const proc = Process.spawn(["dotnet", "tool", "install", "--global", "roslyn-language-server", "--prerelease"], {
stdout: "pipe",
@@ -850,7 +849,7 @@ export const FSharp: Info = {
id: "fsharp",
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
async spawn(root) {
async spawn(root, _ctx, flags) {
let bin = which("fsautocomplete")
if (!bin) {
if (!which("dotnet")) {
@@ -858,7 +857,7 @@ export const FSharp: Info = {
return
}
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
if (flags.disableLspDownload) return
log.info("installing fsautocomplete via dotnet tool")
const proc = Process.spawn(["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin], {
stdout: "pipe",
@@ -967,7 +966,7 @@ export const Clangd: Info = {
id: "clangd",
root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd"]),
extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"],
async spawn(root) {
async spawn(root, _ctx, flags) {
const args = ["--background-index", "--clang-tidy"]
const fromPath = which("clangd")
if (fromPath) {
@@ -1002,7 +1001,7 @@ export const Clangd: Info = {
}
}
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
if (flags.disableLspDownload) return
log.info("downloading clangd from GitHub releases")
const releaseResponse = await fetch("https://api.github.com/repos/clangd/clangd/releases/latest")
@@ -1113,11 +1112,11 @@ export const Svelte: Info = {
id: "svelte",
extensions: [".svelte"],
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
async spawn(root) {
async spawn(root, _ctx, flags) {
let binary = which("svelteserver")
const args: string[] = []
if (!binary) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
if (flags.disableLspDownload) return
const resolved = await Npm.which("svelte-language-server")
if (!resolved) return
binary = resolved
@@ -1140,7 +1139,7 @@ export const Astro: Info = {
id: "astro",
extensions: [".astro"],
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
async spawn(root, ctx) {
async spawn(root, ctx, flags) {
const tsserver = Module.resolve("typescript/lib/tsserver.js", ctx.directory)
if (!tsserver) {
log.info("typescript not found, required for Astro language server")
@@ -1151,7 +1150,7 @@ export const Astro: Info = {
let binary = which("astro-ls")
const args: string[] = []
if (!binary) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
if (flags.disableLspDownload) return
const resolved = await Npm.which("@astrojs/language-server")
if (!resolved) return
binary = resolved
@@ -1201,7 +1200,7 @@ export const JDTLS: Info = {
if (settingsRoot) return settingsRoot
},
extensions: [".java"],
async spawn(root) {
async spawn(root, _ctx, flags) {
const java = which("java")
if (!java) {
log.error("Java 21 or newer is required to run the JDTLS. Please install it first.")
@@ -1219,7 +1218,7 @@ export const JDTLS: Info = {
const launcherDir = path.join(distPath, "plugins")
const installed = await pathExists(launcherDir)
if (!installed) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
if (flags.disableLspDownload) return
log.info("Downloading JDTLS LSP server.")
await fs.mkdir(distPath, { recursive: true })
const releaseURL =
@@ -1311,13 +1310,13 @@ export const KotlinLS: Info = {
// 4) Maven fallback
return NearestRoot(["pom.xml"])(file, ctx)
},
async spawn(root) {
async spawn(root, _ctx, flags) {
const distPath = path.join(Global.Path.bin, "kotlin-ls")
const launcherScript =
process.platform === "win32" ? path.join(distPath, "kotlin-lsp.cmd") : path.join(distPath, "kotlin-lsp.sh")
const installed = await Filesystem.exists(launcherScript)
if (!installed) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
if (flags.disableLspDownload) return
log.info("Downloading Kotlin Language Server from GitHub.")
const releaseResponse = await fetch("https://api.github.com/repos/Kotlin/kotlin-lsp/releases/latest")
@@ -1398,11 +1397,11 @@ export const YamlLS: Info = {
id: "yaml-ls",
extensions: [".yaml", ".yml"],
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
async spawn(root) {
async spawn(root, _ctx, flags) {
let binary = which("yaml-language-server")
const args: string[] = []
if (!binary) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
if (flags.disableLspDownload) return
const resolved = await Npm.which("yaml-language-server")
if (!resolved) return
binary = resolved
@@ -1432,11 +1431,11 @@ export const LuaLS: Info = {
"selene.yml",
]),
extensions: [".lua"],
async spawn(root) {
async spawn(root, _ctx, flags) {
let bin = which("lua-language-server")
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
if (flags.disableLspDownload) return
log.info("downloading lua-language-server from GitHub releases")
const releaseResponse = await fetch("https://api.github.com/repos/LuaLS/lua-language-server/releases/latest")
@@ -1565,11 +1564,11 @@ export const PHPIntelephense: Info = {
id: "php intelephense",
extensions: [".php"],
root: NearestRoot(["composer.json", "composer.lock", ".php-version"]),
async spawn(root) {
async spawn(root, _ctx, flags) {
let binary = which("intelephense")
const args: string[] = []
if (!binary) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
if (flags.disableLspDownload) return
const resolved = await Npm.which("intelephense")
if (!resolved) return
binary = resolved
@@ -1649,11 +1648,11 @@ export const BashLS: Info = {
id: "bash",
extensions: [".sh", ".bash", ".zsh", ".ksh"],
root: async (_file, ctx) => ctx.directory,
async spawn(root) {
async spawn(root, _ctx, flags) {
let binary = which("bash-language-server")
const args: string[] = []
if (!binary) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
if (flags.disableLspDownload) return
const resolved = await Npm.which("bash-language-server")
if (!resolved) return
binary = resolved
@@ -1675,11 +1674,11 @@ export const TerraformLS: Info = {
id: "terraform",
extensions: [".tf", ".tfvars"],
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
async spawn(root) {
async spawn(root, _ctx, flags) {
let bin = which("terraform-ls")
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
if (flags.disableLspDownload) return
log.info("downloading terraform-ls from HashiCorp releases")
const releaseResponse = await fetch("https://api.releases.hashicorp.com/v1/releases/terraform-ls/latest")
@@ -1756,11 +1755,11 @@ export const TexLab: Info = {
id: "texlab",
extensions: [".tex", ".bib"],
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
async spawn(root) {
async spawn(root, _ctx, flags) {
let bin = which("texlab")
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
if (flags.disableLspDownload) return
log.info("downloading texlab from GitHub releases")
const response = await fetch("https://api.github.com/repos/latex-lsp/texlab/releases/latest")
@@ -1844,11 +1843,11 @@ export const DockerfileLS: Info = {
id: "dockerfile",
extensions: [".dockerfile", "Dockerfile"],
root: async (_file, ctx) => ctx.directory,
async spawn(root) {
async spawn(root, _ctx, flags) {
let binary = which("docker-langserver")
const args: string[] = []
if (!binary) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
if (flags.disableLspDownload) return
const resolved = await Npm.which("dockerfile-language-server-nodejs")
if (!resolved) return
binary = resolved
@@ -1940,11 +1939,11 @@ export const Tinymist: Info = {
id: "tinymist",
extensions: [".typ", ".typc"],
root: NearestRoot(["typst.toml"]),
async spawn(root) {
async spawn(root, _ctx, flags) {
let bin = which("tinymist")
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
if (flags.disableLspDownload) return
log.info("downloading tinymist from GitHub releases")
const response = await fetch("https://api.github.com/repos/Myriad-Dreamin/tinymist/releases/latest")
-36
View File
@@ -1,36 +0,0 @@
import { context, type InstanceContext } from "./instance-context"
export type { InstanceContext } from "./instance-context"
export const Instance = {
get current() {
return context.use()
},
get directory() {
return context.use().directory
},
get worktree() {
return context.use().worktree
},
get project() {
return context.use().project
},
/**
* Captures the current instance ALS context and returns a wrapper that
* restores it when called. Use this for callbacks that fire outside the
* instance async context (native addons, event emitters, timers, etc.).
*/
bind<F extends (...args: any[]) => any>(fn: F): F {
const ctx = context.use()
return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F
},
/**
* Run a synchronous function within the given instance context ALS.
* Use this to bridge from Effect (where InstanceRef carries context)
* back to sync code that reads Instance.directory from ALS.
*/
restore<R>(ctx: InstanceContext, fn: () => R): R {
return context.provide(ctx, fn)
},
}
@@ -1,12 +0,0 @@
import { AppRuntime } from "@/effect/app-runtime"
import { context } from "./instance-context"
import { InstanceStore } from "./instance-store"
export async function provide<R>(input: { directory: string; fn: () => R }): Promise<R> {
const ctx = await AppRuntime.runPromise(
InstanceStore.Service.use((store) => store.load({ directory: input.directory })),
)
return context.provide(ctx, () => input.fn())
}
export * as WithInstance from "./with-instance"
+3 -4
View File
@@ -4,7 +4,6 @@ import type { JSONSchema7 } from "@ai-sdk/provider"
import type * as Provider from "./provider"
import type * as ModelsDev from "@opencode-ai/core/models"
import { iife } from "@/util/iife"
import { Flag } from "@opencode-ai/core/flag/flag"
type Modality = NonNullable<ModelsDev.Model["modalities"]>["input"][number]
@@ -16,7 +15,7 @@ function mimeToModality(mime: string): Modality | undefined {
return undefined
}
export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000
export const OUTPUT_TOKEN_MAX = 32_000
export function sanitizeSurrogates(content: string) {
return content.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD")
@@ -1251,8 +1250,8 @@ export function providerOptions(model: Provider.Model, options: { [x: string]: a
return { [key]: options }
}
export function maxOutputTokens(model: Provider.Model): number {
return Math.min(model.limit.output, OUTPUT_TOKEN_MAX) || OUTPUT_TOKEN_MAX
export function maxOutputTokens(model: Provider.Model, outputTokenMax = OUTPUT_TOKEN_MAX): number {
return Math.min(model.limit.output, outputTokenMax) || outputTokenMax
}
export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 {
@@ -1,5 +1,5 @@
import { EffectBridge } from "@/effect/bridge"
import type { InstanceContext } from "@/project/instance"
import type { InstanceContext } from "@/project/instance-context"
import { InstanceStore } from "@/project/instance-store"
import * as Log from "@opencode-ai/core/util/log"
import { Effect } from "effect"
@@ -1,4 +1,4 @@
import { WorkspaceRef } from "@/effect/instance-ref"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { InstanceStore } from "@/project/instance-store"
import { Effect, Layer } from "effect"
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
@@ -26,9 +26,10 @@ function provideInstanceContext<E>(
): Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext> {
return Effect.gen(function* () {
const route = yield* WorkspaceRouteContext
return yield* store.provide(
{ directory: decode(route.directory) },
effect.pipe(Effect.provideService(WorkspaceRef, route.workspaceID)),
const ctx = yield* store.load({ directory: decode(route.directory) })
return yield* effect.pipe(
Effect.provideService(InstanceRef, ctx),
Effect.provideService(WorkspaceRef, route.workspaceID),
)
})
}
@@ -1,8 +1,7 @@
import { getAdapter } from "@/control-plane/adapters"
import { WorkspaceID } from "@/control-plane/schema"
import type { Target } from "@/control-plane/types"
import { Workspace } from "@/control-plane/workspace"
import { EffectBridge } from "@/effect/bridge"
import { WorkspaceAdapterRuntime } from "@/control-plane/workspace-adapter-runtime"
import { Session } from "@/session/session"
import { HttpApiProxy } from "./proxy"
import * as Fence from "@/server/shared/fence"
@@ -93,8 +92,7 @@ function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServe
}
function resolveTarget(workspace: Workspace.Info): Effect.Effect<Target> {
const adapter = getAdapter(workspace.projectID, workspace.type)
return EffectBridge.fromPromise(() => adapter.target(workspace))
return WorkspaceAdapterRuntime.target(workspace)
}
function proxyRemote(
+6 -1
View File
@@ -228,7 +228,12 @@ export const layer = Layer.effect(
tokens: MessageV2.Assistant["tokens"]
model: Provider.Model
}) {
return overflow({ cfg: yield* config.get(), tokens: input.tokens, model: input.model })
return overflow({
cfg: yield* config.get(),
tokens: input.tokens,
model: input.model,
outputTokenMax: flags.outputTokenMax,
})
})
const estimate = Effect.fn("SessionCompaction.estimate")(function* (input: {
+10 -6
View File
@@ -3,6 +3,7 @@ import { Effect, Layer, Context } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Flag } from "@opencode-ai/core/flag/flag"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { withTransientReadRetry } from "@/util/effect-http-client"
@@ -10,9 +11,9 @@ import { Global } from "@opencode-ai/core/global"
import type { MessageV2 } from "./message-v2"
import type { MessageID } from "./schema"
const FILES = [
const files = (disableClaudeCodePrompt: boolean) => [
"AGENTS.md",
...(Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT ? [] : ["CLAUDE.md"]),
...(disableClaudeCodePrompt ? [] : ["CLAUDE.md"]),
"CONTEXT.md", // deprecated
]
@@ -50,18 +51,20 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/In
export const layer: Layer.Layer<
Service,
never,
AppFileSystem.Service | Config.Service | Global.Service | HttpClient.HttpClient
AppFileSystem.Service | Config.Service | Global.Service | HttpClient.HttpClient | RuntimeFlags.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
const cfg = yield* Config.Service
const fs = yield* AppFileSystem.Service
const global = yield* Global.Service
const flags = yield* RuntimeFlags.Service
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
const globalFiles = [
path.join(global.config, "AGENTS.md"),
...(!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT ? [path.join(global.home, ".claude", "CLAUDE.md")] : []),
...(!flags.disableClaudeCodePrompt ? [path.join(global.home, ".claude", "CLAUDE.md")] : []),
]
const instructionFiles = files(flags.disableClaudeCodePrompt)
const state = yield* InstanceState.make(
Effect.fn("Instruction.state")(() =>
@@ -117,7 +120,7 @@ export const layer: Layer.Layer<
// The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of FILES) {
for (const file of instructionFiles) {
const matches = yield* fs
.findUp(file, ctx.directory, ctx.worktree)
.pipe(Effect.catch(() => Effect.succeed([])))
@@ -165,7 +168,7 @@ export const layer: Layer.Layer<
})
const find = Effect.fn("Instruction.find")(function* (dir: string) {
for (const file of FILES) {
for (const file of instructionFiles) {
const filepath = path.resolve(path.join(dir, file))
if (yield* fs.existsSafe(filepath)) return filepath
}
@@ -225,6 +228,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(Global.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(RuntimeFlags.defaultLayer),
)
export function loaded(messages: MessageV2.WithParts[]) {
+2 -2
View File
@@ -173,7 +173,7 @@ const live: Layer.Layer<
: undefined,
topP: input.agent.topP ?? ProviderTransform.topP(input.model),
topK: ProviderTransform.topK(input.model),
maxOutputTokens: ProviderTransform.maxOutputTokens(input.model),
maxOutputTokens: ProviderTransform.maxOutputTokens(input.model, flags.outputTokenMax),
options,
},
)
@@ -256,7 +256,7 @@ const live: Layer.Layer<
const bridge = yield* EffectBridge.make()
const approvedToolsForSession = new Set<string>()
workflowModel.approvalHandler = InstanceState.bind(async (approvalTools) => {
workflowModel.approvalHandler = bridge.bind(async (approvalTools) => {
const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[]
// Auto-approve tools that were already approved in this session
// (prevents infinite approval loops for server-side MCP tools)
+10 -4
View File
@@ -5,18 +5,24 @@ import type { MessageV2 } from "./message-v2"
const COMPACTION_BUFFER = 20_000
export function usable(input: { cfg: Config.Info; model: Provider.Model }) {
export function usable(input: { cfg: Config.Info; model: Provider.Model; outputTokenMax?: number }) {
const context = input.model.limit.context
if (context === 0) return 0
const reserved =
input.cfg.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model))
input.cfg.compaction?.reserved ??
Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model, input.outputTokenMax))
return input.model.limit.input
? Math.max(0, input.model.limit.input - reserved)
: Math.max(0, context - ProviderTransform.maxOutputTokens(input.model))
: Math.max(0, context - ProviderTransform.maxOutputTokens(input.model, input.outputTokenMax))
}
export function isOverflow(input: { cfg: Config.Info; tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
export function isOverflow(input: {
cfg: Config.Info
tokens: MessageV2.Assistant["tokens"]
model: Provider.Model
outputTokenMax?: number
}) {
if (input.cfg.compaction?.auto === false) return false
if (input.model.limit.context === 0) return false
+1 -1
View File
@@ -25,7 +25,7 @@ import { ProjectTable } from "../project/project.sql"
import { Storage } from "@/storage/storage"
import * as Log from "@opencode-ai/core/util/log"
import { MessageV2 } from "./message-v2"
import type { InstanceContext } from "../project/instance"
import type { InstanceContext } from "../project/instance-context"
import { InstanceState } from "@/effect/instance-state"
import { Snapshot } from "@/snapshot"
import { ProjectID } from "../project/schema"
+3 -2
View File
@@ -5,7 +5,6 @@ import { NamedError } from "@opencode-ai/core/util/error"
import type { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
import { InstanceState } from "@/effect/instance-state"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { Permission } from "@/permission"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
@@ -166,6 +165,7 @@ const discoverSkills = Effect.fnUntraced(function* (
discovery: Discovery.Interface,
fsys: AppFileSystem.Interface,
global: Global.Interface,
disableExternalSkills: boolean,
disableClaudeCodeSkills: boolean,
directory: string,
worktree: string,
@@ -173,7 +173,7 @@ const discoverSkills = Effect.fnUntraced(function* (
const state: ScanState = { matches: new Set(), dirs: new Set() }
const externalDirs: string[] = []
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
if (!disableExternalSkills) {
if (!disableClaudeCodeSkills) externalDirs.push(CLAUDE_EXTERNAL_DIR)
externalDirs.push(AGENTS_EXTERNAL_DIR)
@@ -249,6 +249,7 @@ export const layer = Layer.effect(
discovery,
fsys,
global,
flags.disableExternalSkills,
flags.disableClaudeCodeSkills,
ctx.directory,
ctx.worktree,
+8 -8
View File
@@ -11,7 +11,7 @@ import path from "path"
import { readFileSync, readdirSync, existsSync } from "fs"
import { Flag } from "@opencode-ai/core/flag/flag"
import { InstallationChannel } from "@opencode-ai/core/installation/version"
import { InstanceState } from "@/effect/instance-state"
import { EffectBridge } from "@/effect/bridge"
import { init } from "#db"
import { Effect, Schema } from "effect"
@@ -23,19 +23,19 @@ export const NotFoundError = NamedError.create("NotFoundError", {
const log = Log.create({ service: "db" })
type ChannelDbFlags = Pick<RuntimeFlags.Info, "disableChannelDb">
type DatabaseFlags = Pick<RuntimeFlags.Info, "disableChannelDb" | "skipMigrations">
const readRuntimeFlags = () =>
Effect.runSync(RuntimeFlags.Service.useSync((flags) => flags).pipe(Effect.provide(RuntimeFlags.defaultLayer)))
export function getChannelPath(flags: ChannelDbFlags = readRuntimeFlags()) {
export function getChannelPath(flags: Pick<DatabaseFlags, "disableChannelDb"> = readRuntimeFlags()) {
if (["latest", "beta", "prod"].includes(InstallationChannel) || flags.disableChannelDb)
return path.join(Global.Path.data, "opencode.db")
const safe = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")
return path.join(Global.Path.data, `opencode-${safe}.db`)
}
export const getPath = (flags?: ChannelDbFlags) => {
export const getPath = (flags?: Pick<DatabaseFlags, "disableChannelDb">) => {
if (Flag.OPENCODE_DB) {
if (Flag.OPENCODE_DB === ":memory:" || path.isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
return path.join(Global.Path.data, Flag.OPENCODE_DB)
@@ -93,7 +93,7 @@ let client: Client | undefined
let loaded = false
export const Client = Object.assign(
(flags?: ChannelDbFlags): Client => {
(flags: DatabaseFlags = readRuntimeFlags()): Client => {
if (loaded) return client as Client
const dbPath = getPath(flags)
@@ -118,7 +118,7 @@ export const Client = Object.assign(
count: entries.length,
mode: typeof OPENCODE_MIGRATIONS !== "undefined" ? "bundled" : "dev",
})
if (Flag.OPENCODE_SKIP_MIGRATIONS) {
if (flags.skipMigrations) {
for (const item of entries) {
item.sql = "select 1;"
}
@@ -167,7 +167,7 @@ export function use<T>(callback: (trx: TxOrDb) => T): T {
}
export function effect(fn: () => any | Promise<any>) {
const bound = InstanceState.bind(fn)
const bound = EffectBridge.bind(fn)
try {
ctx.use().effects.push(bound)
} catch {
@@ -188,7 +188,7 @@ export function transaction<T>(
} catch (err) {
if (err instanceof LocalContext.NotFound) {
const effects: (() => void | Promise<void>)[] = []
const txCallback = InstanceState.bind((tx: TxOrDb) => ctx.provide({ tx, effects }, () => callback(tx)))
const txCallback = EffectBridge.bind((tx: TxOrDb) => ctx.provide({ tx, effects }, () => callback(tx)))
const result = Client().transaction(txCallback, { behavior: options?.behavior })
for (const effect of effects) effect()
return result as NotPromise<T>
+1 -1
View File
@@ -7,7 +7,7 @@ import { eq } from "drizzle-orm"
import { GlobalBus } from "@/bus/global"
import { Bus as ProjectBus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import type { InstanceContext } from "@/project/instance"
import type { InstanceContext } from "@/project/instance-context"
import { EventSequenceTable, EventTable } from "./event.sql"
import type { WorkspaceID } from "@/control-plane/schema"
import { EventID } from "./schema"
+27 -1
View File
@@ -417,7 +417,7 @@ function legacyJsonSchema(entries: [string, unknown][]): JSONSchema7 {
}
function zodJsonSchema(schema: z.ZodType): JSONSchema7 {
const result = normalizeZodJsonSchema(z.toJSONSchema(schema, { io: "input" }))
const result = normalizeZodJsonSchema(z.toJSONSchema(schema, { io: "input", metadata: zodMetadataRegistry(schema) }))
if (!isJsonSchemaObject(result)) throw new Error("plugin tool Zod schema produced a non-object JSON Schema")
const { $defs, ...rest } = result
return (
@@ -425,6 +425,32 @@ function zodJsonSchema(schema: z.ZodType): JSONSchema7 {
) as JSONSchema7
}
function zodMetadataRegistry(schema: z.ZodType) {
const registry = z.registry<Record<string, unknown>>()
const seen = new WeakSet<object>()
const collect = (value: unknown) => {
if (typeof value !== "object" || value === null) return
if (seen.has(value)) return
seen.add(value)
if (isZodType(value)) {
const metadata = typeof value.meta === "function" ? value.meta() : undefined
const description = typeof value.description === "string" ? value.description : undefined
const merged = {
...(metadata && typeof metadata === "object" ? metadata : {}),
...(description ? { description } : {}),
}
if (Object.keys(merged).length) registry.add(value, merged)
collect(value._zod.def)
return
}
for (const item of Object.values(value)) collect(item)
}
collect(schema)
return registry
}
function normalizeZodJsonSchema(value: unknown): unknown {
if (Array.isArray(value)) return value.map((item) => normalizeZodJsonSchema(item))
if (typeof value !== "object" || value === null) return value
+4 -2
View File
@@ -6,7 +6,7 @@ import { assertExternalDirectoryEffect } from "./external-directory"
import DESCRIPTION from "./repo_overview.txt"
import * as Tool from "./tool"
import { parseRepositoryReference, repositoryCachePath } from "@/util/repository"
import { Instance } from "@/project/instance"
import { InstanceState } from "@/effect/instance-state"
export const Parameters = Schema.Struct({
repository: Schema.optional(Schema.String).annotate({
@@ -108,7 +108,9 @@ export const RepoOverviewTool = Tool.define<typeof Parameters, Metadata, AppFile
params: Schema.Schema.Type<typeof Parameters>,
) {
if (params.path) {
const full = path.isAbsolute(params.path) ? params.path : path.resolve(Instance.directory, params.path)
const full = path.isAbsolute(params.path)
? params.path
: path.resolve(yield* InstanceState.directory, params.path)
return { path: full, repository: params.repository }
}
+5 -5
View File
@@ -609,10 +609,10 @@ export const ShellTool = Tool.define(
parameters: prompt.parameters,
execute: (params: Parameters, ctx: Tool.Context) =>
Effect.gen(function* () {
const executeInstance = yield* InstanceState.context
const instanceCtx = yield* InstanceState.context
const cwd = params.workdir
? yield* resolvePath(params.workdir, executeInstance.directory, shell)
: executeInstance.directory
? yield* resolvePath(params.workdir, instanceCtx.directory, shell)
: instanceCtx.directory
if (params.timeout !== undefined && params.timeout < 0) {
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
}
@@ -623,8 +623,8 @@ export const ShellTool = Tool.define(
const tree = yield* Effect.acquireRelease(parse(params.command, ps), (tree) =>
Effect.sync(() => tree.delete()),
)
const scan = yield* collect(tree.rootNode, cwd, ps, shell, executeInstance)
if (!containsPath(cwd, executeInstance)) scan.dirs.add(cwd)
const scan = yield* collect(tree.rootNode, cwd, ps, shell, instanceCtx)
if (!containsPath(cwd, instanceCtx)) scan.dirs.add(cwd)
yield* ask(ctx, scan)
}),
)
+1 -1
View File
@@ -116,7 +116,7 @@ describe("my service", () => {
Prefer the Effect-aware helpers from `fixture/fixture.ts` instead of building a manual runtime in each test.
- `tmpdirScoped(options?)` creates a scoped temp directory and cleans it up when the Effect scope closes.
- `provideInstance(dir)(effect)` is the low-level helper. It does not create a directory; it just runs an Effect with `Instance.current` bound to `dir`.
- `provideInstance(dir)(effect)` is the low-level helper. It does not create a directory; it runs an Effect with `InstanceRef` provided for `dir`.
- `provideTmpdirInstance((dir) => effect, options?)` is the convenience helper. It creates a temp directory, binds it as the active instance, and disposes the instance on cleanup.
- `provideTmpdirServer((input) => effect, options?)` does the same, but also provides the test LLM server.
@@ -149,7 +149,7 @@ Do not maintain a long file checklist here. It goes stale quickly.
When looking for the next target, search for current anti-patterns:
```bash
git grep -n "Effect.runPromise\|ManagedRuntime\|Promise.withResolvers\|Bun.sleep\|WithInstance" -- packages/opencode/test
git grep -n "Effect.runPromise\|ManagedRuntime\|Promise.withResolvers\|Bun.sleep\|withTestInstance" -- packages/opencode/test
```
Then choose one file or one small cluster, keep the PR focused, and mention
@@ -3,7 +3,6 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Effect } from "effect"
import { fileURLToPath } from "url"
import { InstanceRef } from "../../src/effect/instance-ref"
import { Instance } from "../../src/project/instance"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@@ -13,46 +12,28 @@ afterEach(async () => {
await disposeAllInstances()
})
// Regression for PR #25522: when an effectCmd handler does
// `yield* Effect.promise(async () => { ... await runPromise(svcMethod) ... })`,
// the inner runPromise creates a fresh fiber after `await` whose Effect context
// has lost the outer InstanceRef. Services that read `InstanceState.context`
// then fall back to `Instance.current` ALS, which must be installed at the JS
// callback boundary (Node ALS persists across awaits, Effect's fiber context
// does not). `it.instance` provides the loaded InstanceRef; the explicit
// Instance.restore mirrors effectCmd's load + ALS-restore wrap.
// Pins effect-cmd.ts directly: the pattern test below exercises the load +
// Instance.restore boundary via the shared `it.instance` fixture,
// so a regression that removed `Instance.restore` from effect-cmd.ts wouldn't
// fail it. This grep guards the actual production callsite.
it.live("effect-cmd.ts wraps the handler body in Instance.restore", () =>
it.live("effect-cmd.ts does not restore legacy instance ALS", () =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const source = yield* fs.readFileString(fileURLToPath(new URL("../../src/cli/effect-cmd.ts", import.meta.url)))
expect(source).toContain("Instance.restore(ctx")
expect(source).not.toContain("restore(ctx")
}),
)
it.instance(
"Instance.current reachable after await inside restored Effect.promise(async)",
"InstanceRef remains the handler context across Effect promise awaits",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const ctx = yield* InstanceRef
if (!ctx) throw new Error("InstanceRef not provided")
const current = yield* Effect.promise(() =>
Instance.restore(ctx, async () => {
await Promise.resolve()
try {
return Instance.current
} catch {
return undefined
}
}),
)
const directory = yield* Effect.promise(async () => {
await Promise.resolve()
return ctx.directory
})
expect(current?.directory).toBe(test.directory)
expect(directory).toBe(test.directory)
}),
{ git: true },
)
@@ -126,6 +126,12 @@ describe("run prompt shared", () => {
expect(mentionTriggerIndex("👨‍👩‍👧‍👦 @src", Bun.stringWidth("👨‍👩‍👧‍👦 @src"))).toBe(3)
expect(displayCharAt("👨‍👩‍👧‍👦 @src", Bun.stringWidth("👨‍👩‍👧‍👦 @"))).toBe("s")
expect(displaySlice("👨‍👩‍👧‍👦 @src", 3, Bun.stringWidth("👨‍👩‍👧‍👦 @src"))).toBe("@src")
expect(mentionTriggerIndex("@file1\n@file2", 13)).toBe(7)
expect(displayCharAt("@file1\n@file2", 6)).toBe("\n")
expect(displaySlice("@file1\n@file2", 8, 13)).toBe("file2")
expect(mentionTriggerIndex("@file1\nfoo @file2", 17)).toBe(11)
expect(mentionTriggerIndex("中文 @one\n@two", 14)).toBe(10)
expect(displaySlice("中文 @one\n@two", 11, 14)).toBe("two")
expect(mentionTriggerIndex("中文@")).toBeUndefined()
expect(mentionTriggerIndex("こんにちは@")).toBeUndefined()
expect(mentionTriggerIndex("한국어@")).toBeUndefined()
+218 -189
View File
@@ -6,14 +6,14 @@ import { ConfigManaged } from "@/config/managed"
import { ConfigParse } from "../../src/config/parse"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { InstanceRef } from "../../src/effect/instance-ref"
import type { InstanceContext } from "../../src/project/instance-context"
import { Auth } from "../../src/auth"
import { Account } from "../../src/account/account"
import { AccessToken, AccountID, OrgID } from "../../src/account/schema"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Env } from "../../src/env"
import { provideTestInstance, provideTmpdirInstance } from "../fixture/fixture"
import { provideTestInstance, provideTmpdirInstance, withTestInstance } from "../fixture/fixture"
import { tmpdir } from "../fixture/fixture"
import { InstanceRuntime } from "@/project/instance-runtime"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
@@ -61,9 +61,20 @@ const layer = Config.layer.pipe(
const it = testEffect(layer)
const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer)))
const save = (config: Config.Info) =>
Effect.runPromise(Config.Service.use((svc) => svc.update(config)).pipe(Effect.scoped, Effect.provide(layer)))
const provideCurrentInstance = <A, E, R>(effect: Effect.Effect<A, E, R>, ctx: InstanceContext) =>
effect.pipe(Effect.provideService(InstanceRef, ctx))
const load = (ctx: InstanceContext) =>
Effect.runPromise(
Config.Service.use((svc) => provideCurrentInstance(svc.get(), ctx)).pipe(Effect.scoped, Effect.provide(layer)),
)
const save = (config: Config.Info, ctx: InstanceContext) =>
Effect.runPromise(
Config.Service.use((svc) => provideCurrentInstance(svc.update(config), ctx)).pipe(
Effect.scoped,
Effect.provide(layer),
),
)
const saveGlobal = (config: Config.Info) =>
Effect.runPromise(
Config.Service.use((svc) => svc.updateGlobal(config)).pipe(
@@ -76,10 +87,20 @@ const clear = async (wait = false) => {
await Effect.runPromise(Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(layer)))
if (wait) await InstanceRuntime.disposeAllInstances()
}
const listDirs = () =>
Effect.runPromise(Config.Service.use((svc) => svc.directories()).pipe(Effect.scoped, Effect.provide(layer)))
const ready = () =>
Effect.runPromise(Config.Service.use((svc) => svc.waitForDependencies()).pipe(Effect.scoped, Effect.provide(layer)))
const listDirs = (ctx: InstanceContext) =>
Effect.runPromise(
Config.Service.use((svc) => provideCurrentInstance(svc.directories(), ctx)).pipe(
Effect.scoped,
Effect.provide(layer),
),
)
const ready = (ctx: InstanceContext) =>
Effect.runPromise(
Config.Service.use((svc) => provideCurrentInstance(svc.waitForDependencies(), ctx)).pipe(
Effect.scoped,
Effect.provide(layer),
),
)
// Get managed config directory from environment (set in preload.ts)
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
@@ -114,13 +135,13 @@ async function check(map: (dir: string) => string) {
$schema: "https://opencode.ai/config.json",
snapshot: false,
})
await WithInstance.provide({
await withTestInstance({
directory: map(tmp.path),
fn: async () => {
const cfg = await load()
fn: async (ctx) => {
const cfg = await load(ctx)
expect(cfg.snapshot).toBe(true)
expect(Instance.directory).toBe(Filesystem.resolve(tmp.path))
expect(Instance.project.id).not.toBe(ProjectID.global)
expect(ctx.directory).toBe(Filesystem.resolve(tmp.path))
expect(ctx.project.id).not.toBe(ProjectID.global)
},
})
} finally {
@@ -132,10 +153,10 @@ async function check(map: (dir: string) => string) {
test("loads config with defaults when no files exist", async () => {
await using tmp = await tmpdir()
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.username).toBeDefined()
},
})
@@ -148,10 +169,10 @@ test("creates global jsonc config with schema when no global configs exist", asy
await clear(true)
try {
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
await load()
fn: async (ctx) => {
await load(ctx)
},
})
@@ -173,10 +194,10 @@ test("does not create global config when OPENCODE_CONFIG_DIR is set", async () =
await clear(true)
try {
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
await load()
fn: async (ctx) => {
await load(ctx)
},
})
@@ -199,10 +220,10 @@ test("loads JSON config file", async () => {
})
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.model).toBe("test/model")
expect(config.username).toBe("testuser")
},
@@ -218,10 +239,10 @@ test("loads shell config field", async () => {
})
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.shell).toBe("bash")
},
})
@@ -240,10 +261,10 @@ test("updates config and preserves empty shell sentinel", async () => {
)
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
await save({ shell: "" })
fn: async (ctx) => {
await save({ shell: "" }, ctx)
const writtenConfig = await Filesystem.readJson<{ shell?: string }>(path.join(tmp.path, "config.json"))
expect(writtenConfig.shell).toBe("")
@@ -318,10 +339,10 @@ test("loads formatter boolean config", async () => {
})
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.formatter).toBe(true)
},
})
@@ -336,10 +357,10 @@ test("loads lsp boolean config", async () => {
})
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.lsp).toBe(true)
},
})
@@ -373,10 +394,10 @@ test("ignores legacy tui keys in opencode config", async () => {
})
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.model).toBe("test/model")
expect((config as Record<string, unknown>).theme).toBeUndefined()
expect((config as Record<string, unknown>).tui).toBeUndefined()
@@ -398,10 +419,10 @@ test("loads JSONC config file", async () => {
)
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.model).toBe("test/model")
expect(config.username).toBe("testuser")
},
@@ -426,10 +447,10 @@ test("jsonc overrides json in the same directory", async () => {
})
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.model).toBe("base")
expect(config.username).toBe("base")
},
@@ -449,10 +470,10 @@ test("handles environment variable substitution", async () => {
})
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.username).toBe("test-user")
},
})
@@ -481,10 +502,10 @@ test("preserves env variables when adding $schema to config", async () => {
)
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.username).toBe("secret_value")
// Read the file to verify the env variable was preserved
@@ -578,10 +599,10 @@ test("handles file inclusion substitution", async () => {
})
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.username).toBe("test-user")
},
})
@@ -597,10 +618,10 @@ test("handles file inclusion with replacement tokens", async () => {
})
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.username).toBe("const out = await Bun.$`echo hi`")
},
})
@@ -617,9 +638,9 @@ test("validates config schema and throws on invalid fields", async () => {
})
await provideTestInstance({
directory: tmp.path,
fn: async () => {
fn: async (ctx) => {
// Strict schema should throw an error for invalid fields
await expect(load()).rejects.toThrow()
await expect(load(ctx)).rejects.toThrow()
},
})
})
@@ -632,8 +653,8 @@ test("throws error for invalid JSON", async () => {
})
await provideTestInstance({
directory: tmp.path,
fn: async () => {
await expect(load()).rejects.toThrow()
fn: async (ctx) => {
await expect(load(ctx)).rejects.toThrow()
},
})
})
@@ -653,10 +674,10 @@ test("handles agent configuration", async () => {
})
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.agent?.["test_agent"]).toEqual(
expect.objectContaining({
model: "test/model",
@@ -684,10 +705,10 @@ test("treats agent variant as model-scoped setting (not provider option)", async
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
const agent = config.agent?.["test_agent"]
expect(agent?.variant).toBe("xhigh")
@@ -714,10 +735,10 @@ test("handles command configuration", async () => {
})
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.command?.["test_command"]).toEqual({
template: "test template",
description: "test command",
@@ -739,10 +760,10 @@ test("migrates autoshare to share field", async () => {
)
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.share).toBe("auto")
expect(config.autoshare).toBe(true)
},
@@ -766,10 +787,10 @@ test("migrates mode field to agent field", async () => {
)
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.agent?.["test_mode"]).toEqual({
model: "test/model",
temperature: 0.5,
@@ -798,10 +819,10 @@ Test agent prompt`,
)
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.agent?.["test"]).toEqual(
expect.objectContaining({
name: "test",
@@ -831,10 +852,10 @@ Ordered permissions`,
)
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(Object.keys(config.agent?.ordered?.permission ?? {})).toEqual(["bash", "*", "edit"])
},
})
@@ -869,10 +890,10 @@ Nested agent prompt`,
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.agent?.["helper"]).toMatchObject({
name: "helper",
@@ -918,10 +939,10 @@ Nested command template`,
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.command?.["hello"]).toEqual({
description: "Test command",
@@ -963,10 +984,10 @@ Nested command template`,
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.command?.["hello"]).toEqual({
description: "Test command",
@@ -983,11 +1004,11 @@ Nested command template`,
test("updates config and writes to file", async () => {
await using tmp = await tmpdir()
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
fn: async (ctx) => {
const newConfig = { model: "updated/model" }
await save(newConfig as any)
await save(newConfig as any, ctx)
const writtenConfig = await Filesystem.readJson<{ model: string }>(path.join(tmp.path, "config.json"))
expect(writtenConfig.model).toBe("updated/model")
@@ -997,10 +1018,10 @@ test("updates config and writes to file", async () => {
test("gets config directories", async () => {
await using tmp = await tmpdir()
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const dirs = await listDirs()
fn: async (ctx) => {
const dirs = await listDirs(ctx)
expect(dirs.length).toBeGreaterThanOrEqual(1)
},
})
@@ -1027,10 +1048,10 @@ test("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR", as
process.env.OPENCODE_CONFIG_DIR = tmp.extra
try {
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
await load()
fn: async (ctx) => {
await load(ctx)
},
})
} finally {
@@ -1062,12 +1083,20 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
)
try {
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
await Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(testLayer)))
fn: async (ctx) => {
await Effect.runPromise(
Config.Service.use((svc) => svc.waitForDependencies()).pipe(Effect.scoped, Effect.provide(testLayer)),
Config.Service.use((svc) => svc.get().pipe(Effect.provideService(InstanceRef, ctx))).pipe(
Effect.scoped,
Effect.provide(testLayer),
),
)
await Effect.runPromise(
Config.Service.use((svc) => svc.waitForDependencies().pipe(Effect.provideService(InstanceRef, ctx))).pipe(
Effect.scoped,
Effect.provide(testLayer),
),
)
},
})
@@ -1123,8 +1152,8 @@ test("resolves scoped npm plugins in config", async () => {
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
const pluginEntries = config.plugin ?? []
expect(pluginEntries).toContain("@scope/plugin")
},
@@ -1161,8 +1190,8 @@ test("merges plugin arrays from global and local configs", async () => {
await provideTestInstance({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
const plugins = config.plugin ?? []
// Should contain both global and local plugins
@@ -1195,10 +1224,10 @@ Helper subagent prompt`,
)
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.agent?.["helper"]).toMatchObject({
name: "helper",
model: "test/model",
@@ -1234,10 +1263,10 @@ test("merges instructions arrays from global and local configs", async () => {
},
})
await WithInstance.provide({
await withTestInstance({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
const instructions = config.instructions ?? []
expect(instructions).toContain("global-instructions.md")
@@ -1273,10 +1302,10 @@ test("deduplicates duplicate instructions from global and local configs", async
},
})
await WithInstance.provide({
await withTestInstance({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
const instructions = config.instructions ?? []
expect(instructions).toContain("global-only.md")
@@ -1320,8 +1349,8 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
await provideTestInstance({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
const plugins = config.plugin ?? []
// Should contain all unique plugins
@@ -1369,8 +1398,8 @@ test("keeps plugin origins aligned with merged plugin list", async () => {
await provideTestInstance({
directory: path.join(tmp.path, "project"),
fn: async () => {
const cfg = await load()
fn: async (ctx) => {
const cfg = await load(ctx)
const plugins = cfg.plugin ?? []
const origins = cfg.plugin_origins ?? []
const names = plugins.map((item) => ConfigPlugin.pluginSpecifier(item))
@@ -1408,10 +1437,10 @@ test("migrates legacy tools config to permissions - allow", async () => {
)
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.agent?.["test"]?.permission).toEqual({
bash: "allow",
read: "allow",
@@ -1439,10 +1468,10 @@ test("migrates legacy tools config to permissions - deny", async () => {
)
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.agent?.["test"]?.permission).toEqual({
bash: "deny",
webfetch: "deny",
@@ -1469,10 +1498,10 @@ test("migrates legacy write tool to edit permission", async () => {
)
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.agent?.["test"]?.permission).toEqual({
edit: "allow",
})
@@ -1501,10 +1530,10 @@ test("managed settings override user settings", async () => {
share: "disabled",
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.model).toBe("managed/model")
expect(config.share).toBe("disabled")
expect(config.username).toBe("testuser")
@@ -1529,10 +1558,10 @@ test("managed settings override project settings", async () => {
disabled_providers: ["openai"],
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.autoupdate).toBe(false)
expect(config.disabled_providers).toEqual(["openai"])
},
@@ -1549,10 +1578,10 @@ test("missing managed settings file is not an error", async () => {
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.model).toBe("user/model")
},
})
@@ -1576,10 +1605,10 @@ test("migrates legacy edit tool to edit permission", async () => {
)
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.agent?.["test"]?.permission).toEqual({
edit: "deny",
})
@@ -1605,10 +1634,10 @@ test("migrates legacy patch tool to edit permission", async () => {
)
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.agent?.["test"]?.permission).toEqual({
edit: "allow",
})
@@ -1637,10 +1666,10 @@ test("migrates mixed legacy tools config", async () => {
)
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.agent?.["test"]?.permission).toEqual({
bash: "allow",
edit: "allow",
@@ -1672,10 +1701,10 @@ test("merges legacy tools with existing permission config", async () => {
)
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.agent?.["test"]?.permission).toEqual({
glob: "allow",
bash: "allow",
@@ -1709,10 +1738,10 @@ test("permission config preserves user key order", async () => {
)
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(Object.keys(config.permission!)).toEqual([
"*",
"edit",
@@ -1792,10 +1821,10 @@ test("project config can override MCP server enabled status", async () => {
)
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
// jira should be enabled (overridden by project config)
expect(config.mcp?.jira).toEqual({
type: "remote",
@@ -1848,10 +1877,10 @@ test("MCP config deep merges preserving base config properties", async () => {
)
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.mcp?.myserver).toEqual({
type: "remote",
url: "https://myserver.example.com/mcp",
@@ -1899,10 +1928,10 @@ test("local .opencode config can override MCP from project config", async () =>
)
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.mcp?.docs?.enabled).toBe(true)
},
})
@@ -2235,8 +2264,8 @@ describe("deduplicatePluginOrigins", () => {
await provideTestInstance({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
const plugins = config.plugin ?? []
expect(plugins.some((p) => ConfigPlugin.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true)
@@ -2265,10 +2294,10 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
)
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
// Project config should NOT be loaded - model should be default, not "project/model"
expect(config.model).not.toBe("project/model")
expect(config.username).not.toBe("project-user")
@@ -2296,10 +2325,10 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
await Filesystem.write(path.join(opencodeDir, "test-cmd.md"), "# Test Command\nThis is a test command.")
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const directories = await listDirs()
fn: async (ctx) => {
const directories = await listDirs(ctx)
// Project .opencode should NOT be in directories list
const hasProjectOpencode = directories.some((d) => d.startsWith(tmp.path))
expect(hasProjectOpencode).toBe(false)
@@ -2320,11 +2349,11 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
try {
await using tmp = await tmpdir()
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
fn: async (ctx) => {
// Should still get default config (from global or defaults)
const config = await load()
const config = await load(ctx)
expect(config).toBeDefined()
expect(config.username).toBeDefined()
},
@@ -2362,12 +2391,12 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
fn: async (ctx) => {
// The relative instruction should be skipped without error
// We're mainly verifying this doesn't throw and the config loads
const config = await load()
const config = await load(ctx)
expect(config).toBeDefined()
// The instruction should have been skipped (warning logged)
// We can't easily test the warning was logged, but we verify
@@ -2422,10 +2451,10 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true"
process.env["OPENCODE_CONFIG_DIR"] = configDirTmp.path
await WithInstance.provide({
await withTestInstance({
directory: projectTmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
// Should load from OPENCODE_CONFIG_DIR, not project
expect(config.model).toBe("configdir/model")
},
@@ -2457,10 +2486,10 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
try {
await using tmp = await tmpdir()
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.username).toBe("test_api_key_12345")
},
})
@@ -2491,10 +2520,10 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
})
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.username).toBe("secret_key_from_file")
},
})
@@ -14,8 +14,7 @@ import { GlobalBus, type GlobalEvent } from "@/bus/global"
import { Database } from "@/storage/db"
import { ProjectID } from "@/project/schema"
import { ProjectTable } from "@/project/project.sql"
import { Instance } from "@/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { context, type InstanceContext } from "@/project/instance-context"
import { InstanceRef } from "@/effect/instance-ref"
import { Session as SessionNs } from "@/session/session"
import { SessionID } from "@/session/schema"
@@ -122,12 +121,10 @@ afterEach(async () => {
await resetDatabase()
})
async function withInstance<T>(fn: (dir: string) => T | Promise<T>) {
async function withInstance<T>(fn: (ctx: InstanceContext) => T | Promise<T>) {
await using tmp = await tmpdir({ git: true })
return await WithInstance.provide({
directory: tmp.path,
fn: () => fn(tmp.path),
})
const ctx = await AppRuntime.runPromise(InstanceStore.Service.use((store) => store.load({ directory: tmp.path })))
return await context.provide(ctx, () => fn(ctx))
}
async function initGitRepo(dir: string) {
@@ -142,7 +139,18 @@ async function initGitRepo(dir: string) {
await $`git commit -m "base"`.cwd(dir).quiet()
}
const runWorkspace = <A, E>(effect: Effect.Effect<A, E, Workspace.Service>) => AppRuntime.runPromise(effect)
function currentInstance() {
try {
return context.use()
} catch {
return undefined
}
}
const runWorkspace = <A, E>(effect: Effect.Effect<A, E, Workspace.Service>) => {
const ctx = currentInstance()
return AppRuntime.runPromise(ctx ? effect.pipe(Effect.provideService(InstanceRef, ctx)) : effect)
}
const createWorkspace = (input: Workspace.CreateInput) =>
runWorkspace(Workspace.Service.use((workspace) => workspace.create(input)))
const warpWorkspaceSession = (input: Workspace.SessionWarpInput) =>
@@ -416,16 +424,16 @@ describe("workspace CRUD", () => {
})
test("list maps database rows, filters by project, and sorts by id", async () => {
await withInstance(async () => {
await withInstance(async (instance) => {
const otherProjectID = ProjectID.make("project-other")
insertProject(otherProjectID, "/tmp/other")
const a = workspaceInfo(Instance.project.id, "manual", {
const a = workspaceInfo(instance.project.id, "manual", {
id: WorkspaceID.ascending("wrk_a_list"),
branch: "a",
directory: "/a",
extra: { a: true },
})
const b = workspaceInfo(Instance.project.id, "manual", {
const b = workspaceInfo(instance.project.id, "manual", {
id: WorkspaceID.ascending("wrk_b_list"),
branch: "b",
directory: "/b",
@@ -436,12 +444,12 @@ describe("workspace CRUD", () => {
insertWorkspace(other)
insertWorkspace(a)
expect(await listWorkspaces(Instance.project)).toEqual([a, b])
expect(await listWorkspaces(instance.project)).toEqual([a, b])
})
})
test("create configures, persists, creates, starts local sync, and passes environment", async () => {
await withInstance(async (dir) => {
await withInstance(async (instance) => {
process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ test: { type: "api", key: "secret" } })
process.env.OTEL_EXPORTER_OTLP_HEADERS = "authorization=otel"
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://otel.test"
@@ -449,7 +457,7 @@ describe("workspace CRUD", () => {
const workspaceID = WorkspaceID.ascending("wrk_create_local")
const type = unique("create-local")
const targetDir = path.join(dir, "created-local")
const targetDir = path.join(instance.directory, "created-local")
const recorded = recordedAdapter({
configure(info) {
return {
@@ -467,13 +475,13 @@ describe("workspace CRUD", () => {
return { type: "local", directory: targetDir }
},
})
registerAdapter(Instance.project.id, type, recorded.adapter)
registerAdapter(instance.project.id, type, recorded.adapter)
const info = await createWorkspace({
id: workspaceID,
type,
branch: null,
projectID: Instance.project.id,
projectID: instance.project.id,
extra: null,
})
@@ -484,11 +492,11 @@ describe("workspace CRUD", () => {
name: "Configured Name",
directory: targetDir,
extra: { configured: true },
projectID: Instance.project.id,
projectID: instance.project.id,
timeUsed: info.timeUsed,
})
expect(await getWorkspace(workspaceID)).toEqual(info)
expect(await listWorkspaces(Instance.project)).toEqual([info])
expect(await listWorkspaces(instance.project)).toEqual([info])
expect(recorded.calls.configure).toHaveLength(1)
expect(recorded.calls.configure[0]).toMatchObject({ id: workspaceID, type, directory: null })
expect(recorded.calls.create).toHaveLength(1)
@@ -499,7 +507,7 @@ describe("workspace CRUD", () => {
name: "Configured Name",
directory: targetDir,
extra: { configured: true },
projectID: Instance.project.id,
projectID: instance.project.id,
})
expect(JSON.parse(recorded.calls.create[0].env.OPENCODE_AUTH_CONTENT ?? "{}")).toEqual({
test: { type: "api", key: "secret" },
@@ -517,10 +525,10 @@ describe("workspace CRUD", () => {
})
test("create propagates configure failures and does not insert a workspace", async () => {
await withInstance(async () => {
await withInstance(async (instance) => {
const type = unique("configure-failure")
registerAdapter(
Instance.project.id,
instance.project.id,
type,
recordedAdapter({
configure() {
@@ -533,14 +541,14 @@ describe("workspace CRUD", () => {
)
await expect(
createWorkspace({ type, branch: null, projectID: Instance.project.id, extra: null }),
createWorkspace({ type, branch: null, projectID: instance.project.id, extra: null }),
).rejects.toThrow("configure exploded")
expect(await listWorkspaces(Instance.project)).toEqual([])
expect(await listWorkspaces(instance.project)).toEqual([])
})
})
test("create leaves the inserted row when adapter create fails", async () => {
await withInstance(async () => {
await withInstance(async (instance) => {
const type = unique("create-failure")
const recorded = recordedAdapter({
async create() {
@@ -550,13 +558,13 @@ describe("workspace CRUD", () => {
return { type: "local", directory: "/unused" }
},
})
registerAdapter(Instance.project.id, type, recorded.adapter)
registerAdapter(instance.project.id, type, recorded.adapter)
await expect(
createWorkspace({ type, branch: "branch", projectID: Instance.project.id, extra: { x: 1 } }),
createWorkspace({ type, branch: "branch", projectID: instance.project.id, extra: { x: 1 } }),
).rejects.toThrow("create exploded")
const rows = await listWorkspaces(Instance.project)
const rows = await listWorkspaces(instance.project)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ type, branch: "branch", extra: { x: 1 } })
expect(recorded.calls.target).toHaveLength(0)
@@ -565,13 +573,13 @@ describe("workspace CRUD", () => {
})
test("create returns after a local workspace reports error", async () => {
await withInstance(async (dir) => {
await withInstance(async (instance) => {
const type = unique("local-error")
const missing = path.join(dir, "missing-local-target")
const missing = path.join(instance.directory, "missing-local-target")
const recorded = localAdapter(missing, { createDir: false })
registerAdapter(Instance.project.id, type, recorded.adapter)
registerAdapter(instance.project.id, type, recorded.adapter)
const info = await createWorkspace({ type, branch: null, projectID: Instance.project.id, extra: null })
const info = await createWorkspace({ type, branch: null, projectID: instance.project.id, extra: null })
expect(info.directory).toBe(missing)
expect((await workspaceStatus()).find((item) => item.workspaceID === info.id)?.status).toBe("error")
@@ -580,12 +588,12 @@ describe("workspace CRUD", () => {
})
test("syncList registers adapter-listed workspaces that are missing by name", async () => {
await withInstance(async (dir) => {
await withInstance(async (instance) => {
const type = unique("list-sync")
const existing = workspaceInfo(Instance.project.id, type, {
const existing = workspaceInfo(instance.project.id, type, {
id: WorkspaceID.ascending("wrk_list_sync_existing"),
name: "existing",
directory: path.join(dir, "existing"),
directory: path.join(instance.directory, "existing"),
})
insertWorkspace(existing)
@@ -593,9 +601,9 @@ describe("workspace CRUD", () => {
type,
name: "discovered",
branch: "feature/discovered",
directory: path.join(dir, "discovered"),
directory: path.join(instance.directory, "discovered"),
extra: { source: "adapter" },
projectID: Instance.project.id,
projectID: instance.project.id,
}
const recorded = recordedAdapter({
list() {
@@ -604,26 +612,26 @@ describe("workspace CRUD", () => {
type,
name: existing.name,
branch: "ignored",
directory: path.join(dir, "ignored"),
directory: path.join(instance.directory, "ignored"),
extra: null,
projectID: Instance.project.id,
projectID: instance.project.id,
},
discovered,
]
},
target(info) {
return { type: "local", directory: info.directory ?? dir }
return { type: "local", directory: info.directory ?? instance.directory }
},
})
registerAdapter(Instance.project.id, type, recorded.adapter)
registerAdapter(instance.project.id, type, recorded.adapter)
await syncListWorkspaces(Instance.project)
const synced = (await listWorkspaces(Instance.project)).filter((item) => item.name === discovered.name)
await syncListWorkspaces(instance.project)
const synced = (await listWorkspaces(instance.project)).filter((item) => item.name === discovered.name)
expect(synced).toHaveLength(1)
expect(synced[0]).toMatchObject(discovered)
expect(synced[0]?.id).toStartWith("wrk_")
expect(await listWorkspaces(Instance.project)).toEqual(expect.arrayContaining([existing, synced[0]]))
expect(await listWorkspaces(instance.project)).toEqual(expect.arrayContaining([existing, synced[0]]))
expect(recorded.calls.list).toBe(1)
expect(recorded.calls.configure).toHaveLength(0)
expect(recorded.calls.create).toHaveLength(0)
@@ -632,7 +640,7 @@ describe("workspace CRUD", () => {
})
test("syncList calls every registered adapter with a list method", async () => {
await withInstance(async (dir) => {
await withInstance(async (instance) => {
const typeA = unique("list-sync-a")
const typeB = unique("list-sync-b")
const adapterA = recordedAdapter({
@@ -642,14 +650,14 @@ describe("workspace CRUD", () => {
type: typeA,
name: "adapter-a",
branch: null,
directory: path.join(dir, "adapter-a"),
directory: path.join(instance.directory, "adapter-a"),
extra: null,
projectID: Instance.project.id,
projectID: instance.project.id,
},
]
},
target(info) {
return { type: "local", directory: info.directory ?? dir }
return { type: "local", directory: info.directory ?? instance.directory }
},
})
const adapterB = recordedAdapter({
@@ -659,27 +667,27 @@ describe("workspace CRUD", () => {
type: typeB,
name: "adapter-b",
branch: null,
directory: path.join(dir, "adapter-b"),
directory: path.join(instance.directory, "adapter-b"),
extra: null,
projectID: Instance.project.id,
projectID: instance.project.id,
},
]
},
target(info) {
return { type: "local", directory: info.directory ?? dir }
return { type: "local", directory: info.directory ?? instance.directory }
},
})
const noList = recordedAdapter({
target() {
return { type: "local", directory: dir }
return { type: "local", directory: instance.directory }
},
})
registerAdapter(Instance.project.id, typeA, adapterA.adapter)
registerAdapter(Instance.project.id, typeB, adapterB.adapter)
registerAdapter(Instance.project.id, unique("list-sync-none"), noList.adapter)
registerAdapter(instance.project.id, typeA, adapterA.adapter)
registerAdapter(instance.project.id, typeB, adapterB.adapter)
registerAdapter(instance.project.id, unique("list-sync-none"), noList.adapter)
await syncListWorkspaces(Instance.project)
const synced = await listWorkspaces(Instance.project)
await syncListWorkspaces(instance.project)
const synced = await listWorkspaces(instance.project)
expect(
synced
@@ -719,11 +727,13 @@ describe("workspace CRUD", () => {
(dir) =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const instance = yield* InstanceRef
if (!instance) return yield* Effect.die(new Error("missing test instance"))
const type = unique("remote-create")
const recorded = remoteAdapter(`${url}/base/?ignored=1#hash`, { directory: dir })
registerAdapter(Instance.project.id, type, recorded.adapter)
registerAdapter(instance.project.id, type, recorded.adapter)
const info = yield* workspace.create({ type, branch: null, projectID: Instance.project.id, extra: null })
const info = yield* workspace.create({ type, branch: null, projectID: instance.project.id, extra: null })
expect(
calls.map((call) => `${call.method} ${call.url.pathname}${call.url.search}${call.url.hash}`),
@@ -782,11 +792,11 @@ describe("workspace CRUD", () => {
)
test("remove still deletes the row when the adapter cannot remove resources", async () => {
await withInstance(async () => {
await withInstance(async (instance) => {
const type = unique("remove-throws")
const info = workspaceInfo(Instance.project.id, type, { id: WorkspaceID.ascending("wrk_remove_throws") })
const info = workspaceInfo(instance.project.id, type, { id: WorkspaceID.ascending("wrk_remove_throws") })
registerAdapter(
Instance.project.id,
instance.project.id,
type,
recordedAdapter({
async remove() {
@@ -911,24 +921,26 @@ describe("workspace CRUD", () => {
)
test("sessionWarp detaches to the source project when invoked from a workspace instance", async () => {
await withInstance(async () => {
const projectID = Instance.project.id
await withInstance(async (instance) => {
const projectID = instance.project.id
await using workspaceTmp = await tmpdir({ git: true })
const previousType = unique("warp-detach-workspace-instance")
const previous = workspaceInfo(projectID, previousType)
insertWorkspace(previous)
registerAdapter(projectID, previousType, localAdapter(workspaceTmp.path, { createDir: false }).adapter)
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
const session = await AppRuntime.runPromise(
SessionNs.Service.use((svc) => svc.create({})).pipe(Effect.provideService(InstanceRef, instance)),
)
attachSessionToWorkspace(session.id, previous.id)
const workspaceProjectID = await WithInstance.provide({
directory: workspaceTmp.path,
fn: async () => {
const id = Instance.project.id
expect(id).not.toBe(projectID)
await warpWorkspaceSession({ workspaceID: null, sessionID: session.id })
return id
},
const workspaceCtx = await AppRuntime.runPromise(
InstanceStore.Service.use((store) => store.load({ directory: workspaceTmp.path })),
)
const workspaceProjectID = await context.provide(workspaceCtx, async () => {
const id = workspaceCtx.project.id
expect(id).not.toBe(projectID)
await warpWorkspaceSession({ workspaceID: null, sessionID: session.id })
return id
})
expect(
@@ -988,14 +1000,16 @@ describe("workspace CRUD", () => {
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const sessionSvc = yield* SessionNs.Service
const instance = yield* InstanceRef
if (!instance) return yield* Effect.die(new Error("missing test instance"))
const previousType = unique("warp-remote-source")
const targetType = unique("warp-remote-target")
const previous = workspaceInfo(Instance.project.id, previousType)
const target = workspaceInfo(Instance.project.id, targetType, { directory: "remote-target-dir" })
const previous = workspaceInfo(instance.project.id, previousType)
const target = workspaceInfo(instance.project.id, targetType, { directory: "remote-target-dir" })
insertWorkspace(previous)
insertWorkspace(target)
registerAdapter(Instance.project.id, previousType, remoteAdapter(`${url}/warp-source`).adapter)
registerAdapter(Instance.project.id, targetType, remoteAdapter(`${url}/warp-target`).adapter)
registerAdapter(instance.project.id, previousType, remoteAdapter(`${url}/warp-source`).adapter)
registerAdapter(instance.project.id, targetType, remoteAdapter(`${url}/warp-target`).adapter)
const session = yield* sessionSvc.create({})
attachSessionToWorkspace(session.id, previous.id)
historySessionID = session.id
@@ -1197,15 +1211,17 @@ describe("workspace sync state", () => {
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const sessionSvc = yield* SessionNs.Service
const instance = yield* InstanceRef
if (!instance) return yield* Effect.die(new Error("missing test instance"))
const captured = captureGlobalEvents()
try {
const type = unique("remote-start")
const info = workspaceInfo(Instance.project.id, type)
const info = workspaceInfo(instance.project.id, type)
insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/sync`).adapter)
registerAdapter(instance.project.id, type, remoteAdapter(`${url}/sync`).adapter)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* workspace.startWorkspaceSyncing(Instance.project.id)
yield* workspace.startWorkspaceSyncing(instance.project.id)
yield* eventuallyEffect(
Effect.gen(function* () {
expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe(
@@ -1213,7 +1229,7 @@ describe("workspace sync state", () => {
)
}),
)
yield* workspace.startWorkspaceSyncing(Instance.project.id)
yield* workspace.startWorkspaceSyncing(instance.project.id)
yield* Effect.sleep("25 millis")
expect(
@@ -1252,13 +1268,15 @@ describe("workspace sync state", () => {
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const sessionSvc = yield* SessionNs.Service
const instance = yield* InstanceRef
if (!instance) return yield* Effect.die(new Error("missing test instance"))
const type = unique("remote-connect-fail")
const info = workspaceInfo(Instance.project.id, type)
const info = workspaceInfo(instance.project.id, type)
insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/failed`).adapter)
registerAdapter(instance.project.id, type, remoteAdapter(`${url}/failed`).adapter)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* workspace.startWorkspaceSyncing(Instance.project.id)
yield* workspace.startWorkspaceSyncing(instance.project.id)
yield* eventuallyEffect(
Effect.gen(function* () {
@@ -1292,13 +1310,15 @@ describe("workspace sync state", () => {
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const sessionSvc = yield* SessionNs.Service
const instance = yield* InstanceRef
if (!instance) return yield* Effect.die(new Error("missing test instance"))
const type = unique("remote-history-fail")
const info = workspaceInfo(Instance.project.id, type)
const info = workspaceInfo(instance.project.id, type)
insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/history-failed`).adapter)
registerAdapter(instance.project.id, type, remoteAdapter(`${url}/history-failed`).adapter)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* workspace.startWorkspaceSyncing(Instance.project.id)
yield* workspace.startWorkspaceSyncing(instance.project.id)
yield* eventuallyEffect(
Effect.gen(function* () {
@@ -1347,18 +1367,20 @@ describe("workspace sync state", () => {
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const sessionSvc = yield* SessionNs.Service
const instance = yield* InstanceRef
if (!instance) return yield* Effect.die(new Error("missing test instance"))
const captured = captureGlobalEvents()
try {
const type = unique("history-replay")
const info = workspaceInfo(Instance.project.id, type)
const info = workspaceInfo(instance.project.id, type)
insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/history`).adapter)
registerAdapter(instance.project.id, type, remoteAdapter(`${url}/history`).adapter)
const session = yield* sessionSvc.create({ title: "before history" })
attachSessionToWorkspace(session.id, info.id)
historySessionID = session.id
historyNextSeq = (sessionSequence(session.id) ?? -1) + 1
yield* workspace.startWorkspaceSyncing(Instance.project.id)
yield* workspace.startWorkspaceSyncing(instance.project.id)
yield* eventuallyEffect(
Effect.gen(function* () {
@@ -1414,15 +1436,17 @@ describe("workspace sync state", () => {
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const sessionSvc = yield* SessionNs.Service
const instance = yield* InstanceRef
if (!instance) return yield* Effect.die(new Error("missing test instance"))
const captured = captureGlobalEvents()
try {
const type = unique("sse-forward")
const info = workspaceInfo(Instance.project.id, type)
const info = workspaceInfo(instance.project.id, type)
insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/sse-forward`).adapter)
registerAdapter(instance.project.id, type, remoteAdapter(`${url}/sse-forward`).adapter)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* workspace.startWorkspaceSyncing(Instance.project.id)
yield* workspace.startWorkspaceSyncing(instance.project.id)
yield* eventuallyEffect(
Effect.sync(() =>
@@ -1495,18 +1519,20 @@ describe("workspace sync state", () => {
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const sessionSvc = yield* SessionNs.Service
const instance = yield* InstanceRef
if (!instance) return yield* Effect.die(new Error("missing test instance"))
const captured = captureGlobalEvents()
try {
const type = unique("sse-sync")
const info = workspaceInfo(Instance.project.id, type)
const info = workspaceInfo(instance.project.id, type)
insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/sse-sync`).adapter)
registerAdapter(instance.project.id, type, remoteAdapter(`${url}/sse-sync`).adapter)
const session = yield* sessionSvc.create({ title: "before sse" })
attachSessionToWorkspace(session.id, info.id)
sseSessionID = session.id
sseNextSeq = (sessionSequence(session.id) ?? -1) + 1
yield* workspace.startWorkspaceSyncing(Instance.project.id)
yield* workspace.startWorkspaceSyncing(instance.project.id)
yield* eventuallyEffect(
Effect.gen(function* () {
@@ -1,16 +1,21 @@
import { afterEach, expect } from "bun:test"
import { expect } from "bun:test"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { $ } from "bun"
import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { Instance } from "../../src/project/instance"
import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture"
import {
disposeAllInstancesEffect,
provideInstanceEffect,
reloadInstance,
testInstanceStoreLayer,
tmpdirScoped,
} from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(CrossSpawnSpawner.defaultLayer)
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, testInstanceStoreLayer))
const access = <A, E>(state: InstanceState.InstanceState<A, E>, dir: string) =>
InstanceState.get(state).pipe(provideInstance(dir))
InstanceState.get(state).pipe(provideInstanceEffect(dir))
const tmpdirGitScoped = Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
@@ -18,10 +23,6 @@ const tmpdirGitScoped = Effect.gen(function* () {
return dir
})
afterEach(async () => {
await disposeAllInstances()
})
it.live("InstanceState caches values per directory", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
@@ -69,7 +70,7 @@ it.live("InstanceState invalidates on reload", () =>
)
const a = yield* access(state, dir)
yield* Effect.promise(() => reloadTestInstance({ directory: dir }))
yield* reloadInstance({ directory: dir })
const b = yield* access(state, dir)
expect(a).not.toBe(b)
@@ -94,7 +95,7 @@ it.live("InstanceState invalidates on disposeAll", () =>
yield* access(state, one)
yield* access(state, two)
yield* Effect.promise(disposeAllInstances)
yield* disposeAllInstancesEffect
expect(seen.sort()).toEqual([one, two].sort())
}),
@@ -126,8 +127,8 @@ it.live("InstanceState.get reads the current directory lazily", () =>
}
yield* Effect.gen(function* () {
const a = yield* Test.use((svc) => svc.get()).pipe(provideInstance(one))
const b = yield* Test.use((svc) => svc.get()).pipe(provideInstance(two))
const a = yield* Test.use((svc) => svc.get()).pipe(provideInstanceEffect(one))
const b = yield* Test.use((svc) => svc.get()).pipe(provideInstanceEffect(two))
expect(a).toBe(one)
expect(b).toBe(two)
@@ -178,7 +179,7 @@ it.live("InstanceState preserves directory across async boundaries", () =>
yield* Effect.gen(function* () {
const [a, b, c] = yield* Effect.all(
[one, two, three].map((dir) => Test.use((svc) => svc.get()).pipe(provideInstance(dir))),
[one, two, three].map((dir) => Test.use((svc) => svc.get()).pipe(provideInstanceEffect(dir))),
{ concurrency: "unbounded" },
)
@@ -225,7 +226,7 @@ it.live("InstanceState survives high-contention concurrent access", () =>
yield* Effect.gen(function* () {
const results = yield* Effect.all(
dirs.map((dir) => Test.use((svc) => svc.get()).pipe(provideInstance(dir))),
dirs.map((dir) => Test.use((svc) => svc.get()).pipe(provideInstanceEffect(dir))),
{ concurrency: "unbounded" },
)
@@ -264,19 +265,16 @@ it.live("InstanceState correct after interleaved init and dispose", () =>
}
yield* Effect.gen(function* () {
const a = yield* Test.use((svc) => svc.get()).pipe(provideInstance(one))
const a = yield* Test.use((svc) => svc.get()).pipe(provideInstanceEffect(one))
expect(a).toBe(one)
const [, b] = yield* Effect.all(
[
Effect.promise(() => reloadTestInstance({ directory: one })),
Test.use((svc) => svc.get()).pipe(provideInstance(two)),
],
[reloadInstance({ directory: one }), Test.use((svc) => svc.get()).pipe(provideInstanceEffect(two))],
{ concurrency: "unbounded" },
)
expect(b).toBe(two)
const c = yield* Test.use((svc) => svc.get()).pipe(provideInstance(one))
const c = yield* Test.use((svc) => svc.get()).pipe(provideInstanceEffect(one))
expect(c).toBe(one)
}).pipe(Effect.provide(Test.layer))
}),
@@ -344,9 +342,9 @@ it.live("InstanceState survives deferred resume from the same instance context",
yield* Effect.gen(function* () {
const gate = yield* Deferred.make<void>()
const fiber = yield* Test.use((svc) => svc.get(gate)).pipe(provideInstance(dir), Effect.forkScoped)
const fiber = yield* Test.use((svc) => svc.get(gate)).pipe(provideInstanceEffect(dir), Effect.forkScoped)
yield* Deferred.succeed(gate, undefined).pipe(provideInstance(dir))
yield* Deferred.succeed(gate, undefined).pipe(provideInstanceEffect(dir))
const exit = yield* Fiber.await(fiber)
expect(Exit.isSuccess(exit)).toBe(true)
@@ -381,7 +379,7 @@ it.live("InstanceState survives deferred resume outside ALS when InstanceRef is
yield* Effect.gen(function* () {
const gate = yield* Deferred.make<void>()
const fiber = yield* Test.use((svc) => svc.get(gate)).pipe(provideInstance(dir), Effect.forkScoped)
const fiber = yield* Test.use((svc) => svc.get(gate)).pipe(provideInstanceEffect(dir), Effect.forkScoped)
yield* Deferred.succeed(gate, undefined)
const exit = yield* Fiber.await(fiber)
@@ -27,6 +27,9 @@ describe("RuntimeFlags", () => {
OPENCODE_DISABLE_CHANNEL_DB: "true",
OPENCODE_AUTO_SHARE: "true",
OPENCODE_DISABLE_EMBEDDED_WEB_UI: "true",
OPENCODE_DISABLE_EXTERNAL_SKILLS: "true",
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
OPENCODE_SKIP_MIGRATIONS: "true",
OPENCODE_EXPERIMENTAL: "true",
OPENCODE_ENABLE_EXA: "true",
OPENCODE_ENABLE_PARALLEL: "true",
@@ -42,6 +45,10 @@ describe("RuntimeFlags", () => {
expect(flags.disableDefaultPlugins).toBe(true)
expect(flags.disableChannelDb).toBe(true)
expect(flags.disableEmbeddedWebUi).toBe(true)
expect(flags.disableExternalSkills).toBe(true)
expect(flags.disableLspDownload).toBe(true)
expect(flags.skipMigrations).toBe(true)
expect(flags.disableClaudeCodePrompt).toBe(false)
expect(flags.enableExa).toBe(true)
expect(flags.enableParallel).toBe(true)
expect(flags.enableExperimentalModels).toBe(true)
@@ -84,10 +91,15 @@ describe("RuntimeFlags", () => {
expect(flags.disableDefaultPlugins).toBe(true)
expect(flags.disableChannelDb).toBe(false)
expect(flags.disableEmbeddedWebUi).toBe(false)
expect(flags.disableExternalSkills).toBe(false)
expect(flags.disableLspDownload).toBe(false)
expect(flags.skipMigrations).toBe(false)
expect(flags.disableClaudeCodePrompt).toBe(false)
expect(flags.disableClaudeCodeSkills).toBe(false)
expect(flags.enableExa).toBe(false)
expect(flags.experimentalIconDiscovery).toBe(false)
expect(flags.experimentalOxfmt).toBe(false)
expect(flags.outputTokenMax).toBeUndefined()
expect(flags.bashDefaultTimeoutMs).toBe(1_000)
expect(flags.enableExperimentalModels).toBe(false)
expect(flags.client).toBe("cli")
@@ -102,6 +114,78 @@ describe("RuntimeFlags", () => {
}),
)
it.effect("disableExternalSkills defaults to false", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({})))
expect(flags.disableExternalSkills).toBe(false)
}),
)
it.effect("disableExternalSkills reads OPENCODE_DISABLE_EXTERNAL_SKILLS", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_DISABLE_EXTERNAL_SKILLS: "true" })))
expect(flags.disableExternalSkills).toBe(true)
}),
)
it.effect("disableLspDownload defaults to false", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({})))
expect(flags.disableLspDownload).toBe(false)
}),
)
it.effect("disableLspDownload reads OPENCODE_DISABLE_LSP_DOWNLOAD", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_DISABLE_LSP_DOWNLOAD: "true" })))
expect(flags.disableLspDownload).toBe(true)
}),
)
it.effect("skipMigrations defaults to false", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({})))
expect(flags.skipMigrations).toBe(false)
}),
)
it.effect("skipMigrations reads OPENCODE_SKIP_MIGRATIONS", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_SKIP_MIGRATIONS: "true" })))
expect(flags.skipMigrations).toBe(true)
}),
)
it.effect("disableClaudeCodePrompt defaults to false", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({})))
expect(flags.disableClaudeCodePrompt).toBe(false)
}),
)
it.effect("disableClaudeCodePrompt reads OPENCODE_DISABLE_CLAUDE_CODE_PROMPT", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: "true" })))
expect(flags.disableClaudeCodePrompt).toBe(true)
}),
)
it.effect("disableClaudeCodePrompt inherits OPENCODE_DISABLE_CLAUDE_CODE", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_DISABLE_CLAUDE_CODE: "true" })))
expect(flags.disableClaudeCodePrompt).toBe(true)
}),
)
it.effect("experimentalIconDiscovery reads OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true" })))
@@ -183,6 +267,35 @@ describe("RuntimeFlags", () => {
)
}
for (const input of [
{ name: "absent", config: {}, expected: undefined },
{
name: "valid positive integer",
config: { OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: "1234" },
expected: 1234,
},
{
name: "invalid string",
config: { OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: "nope" },
expected: undefined,
},
{ name: "zero", config: { OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: "0" }, expected: undefined },
{ name: "negative", config: { OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: "-1" }, expected: undefined },
{
name: "non-integer",
config: { OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: "1.5" },
expected: undefined,
},
]) {
it.effect(`parses outputTokenMax from config: ${input.name}`, () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig(input.config)))
expect(flags.outputTokenMax).toBe(input.expected)
}),
)
}
it.effect("layer ignores the active ConfigProvider for omitted test overrides", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(
@@ -192,6 +305,9 @@ describe("RuntimeFlags", () => {
ConfigProvider.fromUnknown({
OPENCODE_PURE: "true",
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
OPENCODE_DISABLE_EXTERNAL_SKILLS: "true",
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
OPENCODE_SKIP_MIGRATIONS: "true",
OPENCODE_EXPERIMENTAL: "true",
OPENCODE_ENABLE_EXA: "true",
OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "1234",
@@ -205,10 +321,15 @@ describe("RuntimeFlags", () => {
expect(flags.disableDefaultPlugins).toBe(false)
expect(flags.disableChannelDb).toBe(false)
expect(flags.disableEmbeddedWebUi).toBe(false)
expect(flags.disableExternalSkills).toBe(false)
expect(flags.disableLspDownload).toBe(false)
expect(flags.skipMigrations).toBe(false)
expect(flags.disableClaudeCodePrompt).toBe(false)
expect(flags.disableClaudeCodeSkills).toBe(false)
expect(flags.enableExa).toBe(false)
expect(flags.experimentalIconDiscovery).toBe(false)
expect(flags.experimentalOxfmt).toBe(false)
expect(flags.outputTokenMax).toBeUndefined()
expect(flags.bashDefaultTimeoutMs).toBeUndefined()
expect(flags.client).toBe("cli")
}),
@@ -260,4 +260,58 @@ describeWatcher("FileWatcher", () => {
}),
{ git: true },
)
// Symlink support varies by platform; skip where unavailable
const describeSymlink = process.platform !== "win32" ? describe : describe.skip
describeSymlink("symlinked .git", () => {
it.instance(
"publishes .git/HEAD events through a symlinked .git directory",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
const dir = test.directory
const actualGit = path.join(dir, "..", "tmp_actual_git_" + Math.random().toString(36).slice(2))
// Move .git to a sibling directory and replace with a symlink
yield* Effect.promise(() => import("fs")).pipe(
Effect.flatMap((nodeFs) =>
Effect.all([
Effect.promise(() => nodeFs.promises.rename(path.join(dir, ".git"), actualGit)),
Effect.promise(() => nodeFs.promises.symlink(actualGit, path.join(dir, ".git"))),
]),
),
)
yield* Effect.acquireRelease(Effect.succeed(actualGit), (p) =>
Effect.promise(() =>
import("fs").then((f) => f.promises.rm(p, { recursive: true, force: true }).catch(() => undefined)),
),
)
const head = path.join(dir, ".git", "HEAD")
const branch = `watch-${Math.random().toString(36).slice(2)}`
yield* git.run(["branch", branch], { cwd: dir })
yield* withWatcher(
dir,
nextUpdate(
dir,
(evt) => evt.file === path.join(actualGit, "HEAD") && evt.event !== "unlink",
fs.writeFileString(head, `ref: refs/heads/${branch}\n`),
).pipe(
Effect.tap((evt) =>
Effect.sync(() => {
expect(evt.file).toBe(path.join(actualGit, "HEAD"))
expect(["add", "change"]).toContain(evt.event)
}),
),
),
)
}),
{ git: true },
)
})
})
+26 -16
View File
@@ -11,29 +11,36 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import type { Config } from "@/config/config"
import { InstanceRef } from "../../src/effect/instance-ref"
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
import type { InstanceContext } from "../../src/project/instance-context"
import { InstanceRuntime } from "../../src/project/instance-runtime"
import { InstanceStore } from "../../src/project/instance-store"
import { Instance } from "../../src/project/instance"
import { TestLLMServer } from "../lib/llm-server"
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
const testInstanceRuntime = ManagedRuntime.make(
InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap), Layer.provideMerge(Observability.layer)),
)
export const testInstanceStoreLayer = InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap))
const testInstanceRuntime = ManagedRuntime.make(testInstanceStoreLayer.pipe(Layer.provideMerge(Observability.layer)))
const runTestInstanceStore = <A>(fn: (store: InstanceStore.Interface) => Effect.Effect<A>) =>
testInstanceRuntime.runPromise(InstanceStore.Service.use(fn))
export async function provideTestInstance<R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) {
export async function provideTestInstance<R>(input: {
directory: string
init?: Effect.Effect<void>
fn: (ctx: InstanceContext) => R
}) {
const ctx = await runTestInstanceStore((store) => store.load({ directory: input.directory }))
try {
if (input.init) await testInstanceRuntime.runPromise(input.init.pipe(Effect.provideService(InstanceRef, ctx)))
return await Instance.restore(ctx, () => input.fn())
return await input.fn(ctx)
} finally {
await runTestInstanceStore((store) => store.dispose(ctx))
}
}
export async function withTestInstance<R>(input: { directory: string; fn: (ctx: InstanceContext) => R }) {
return input.fn(await runTestInstanceStore((store) => store.load({ directory: input.directory })))
}
export async function reloadTestInstance(input: { directory: string }) {
return runTestInstanceStore((store) => store.reload(input))
}
@@ -157,12 +164,20 @@ export const provideInstance =
Effect.contextWith((services: Context.Context<R>) =>
Effect.promise<A>(async () => {
const ctx = await runTestInstanceStore((store) => store.load({ directory }))
return Instance.restore(ctx, () =>
Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, ctx))),
)
return Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, ctx)))
}),
)
export const provideInstanceEffect =
(directory: string) =>
<A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R | InstanceStore.Service> =>
InstanceStore.Service.use((store) => store.provide({ directory }, self))
export const reloadInstance = (input: InstanceStore.LoadInput) =>
InstanceStore.Service.use((store) => store.reload(input))
export const disposeAllInstancesEffect = InstanceStore.Service.use((store) => store.disposeAll())
export function provideTmpdirInstance<A, E, R>(
self: (path: string) => Effect.Effect<A, E, R>,
options?: { git?: boolean; config?: Partial<Config.Info> },
@@ -193,13 +208,8 @@ export const withTmpdirInstance =
<A, E, R>(self: Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped(options)
return yield* InstanceStore.Service.use((store) =>
store.provide({ directory }, self.pipe(Effect.provideService(TestInstance, { directory }))),
)
}).pipe(
Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap))),
Effect.provide(CrossSpawnSpawner.defaultLayer),
)
return yield* self.pipe(Effect.provideService(TestInstance, { directory }), provideInstanceEffect(directory))
}).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer))
export function provideTmpdirServer<A, E, R>(
self: (input: { dir: string; llm: TestLLMServer["Service"] }) => Effect.Effect<A, E, R>,
+37 -27
View File
@@ -1,11 +1,9 @@
import { beforeEach, describe, expect, test } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../fixture/fixture"
import { tmpdir, withTestInstance } from "../fixture/fixture"
import { LSPClient } from "@/lsp/client"
import * as LSPServer from "@/lsp/server"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import * as Log from "@opencode-ai/core/util/log"
function spawnFakeServer() {
@@ -26,14 +24,15 @@ describe("LSPClient interop", () => {
test("handles workspace/workspaceFolders request", async () => {
const handle = spawnFakeServer() as any
const client = await WithInstance.provide({
const client = await withTestInstance({
directory: process.cwd(),
fn: () =>
fn: (ctx) =>
LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
directory: process.cwd(),
instance: ctx,
}),
})
@@ -49,14 +48,15 @@ describe("LSPClient interop", () => {
test("handles client/registerCapability request", async () => {
const handle = spawnFakeServer() as any
const client = await WithInstance.provide({
const client = await withTestInstance({
directory: process.cwd(),
fn: () =>
fn: (ctx) =>
LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
directory: process.cwd(),
instance: ctx,
}),
})
@@ -72,14 +72,15 @@ describe("LSPClient interop", () => {
test("handles client/unregisterCapability request", async () => {
const handle = spawnFakeServer() as any
const client = await WithInstance.provide({
const client = await withTestInstance({
directory: process.cwd(),
fn: () =>
fn: (ctx) =>
LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
directory: process.cwd(),
instance: ctx,
}),
})
@@ -95,14 +96,15 @@ describe("LSPClient interop", () => {
test("initialize does not overclaim unsupported diagnostics capabilities", async () => {
const handle = spawnFakeServer() as any
const client = await WithInstance.provide({
const client = await withTestInstance({
directory: process.cwd(),
fn: () =>
fn: (ctx) =>
LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
directory: process.cwd(),
instance: ctx,
}),
})
@@ -122,9 +124,9 @@ describe("LSPClient interop", () => {
gamma: true,
}
const client = await WithInstance.provide({
const client = await withTestInstance({
directory: process.cwd(),
fn: () =>
fn: (ctx) =>
LSPClient.create({
serverID: "fake",
server: {
@@ -133,6 +135,7 @@ describe("LSPClient interop", () => {
},
root: process.cwd(),
directory: process.cwd(),
instance: ctx,
}),
})
@@ -151,14 +154,15 @@ describe("LSPClient interop", () => {
const file = path.join(tmp.path, "client.ts")
await Bun.write(file, "first\n")
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
await client.notify.open({ path: file })
@@ -194,14 +198,15 @@ describe("LSPClient interop", () => {
const file = path.join(tmp.path, "client.ts")
await Bun.write(file, "const x = 1\n")
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
const version = await client.notify.open({ path: file })
@@ -240,14 +245,15 @@ describe("LSPClient interop", () => {
const file = path.join(tmp.path, "client.ts")
await Bun.write(file, "const x = 1\n")
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
const version = await client.notify.open({ path: file })
@@ -287,14 +293,15 @@ describe("LSPClient interop", () => {
const file = path.join(tmp.path, "client.cs")
await Bun.write(file, "class C {}\n")
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
await client.connection.sendRequest("test/configure-pull-diagnostics", {
@@ -335,14 +342,15 @@ describe("LSPClient interop", () => {
const file = path.join(tmp.path, "client.cs")
await Bun.write(file, "class C {}\n")
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
await client.connection.sendRequest("test/configure-pull-diagnostics", {
@@ -388,14 +396,15 @@ describe("LSPClient interop", () => {
await Bun.write(file, "class C {}\n")
await Bun.write(related, "class D {}\n")
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
await client.connection.sendRequest("test/configure-pull-diagnostics", {
@@ -452,14 +461,15 @@ describe("LSPClient interop", () => {
const file = path.join(tmp.path, "client.cs")
await Bun.write(file, "class C {}\n")
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
await client.connection.sendRequest("test/configure-pull-diagnostics", {
+30
View File
@@ -16,6 +16,12 @@ const experimentalTyIt = testEffect(
CrossSpawnSpawner.defaultLayer,
),
)
const disabledDownloadIt = testEffect(
Layer.mergeAll(
LSP.layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(RuntimeFlags.layer({ disableLspDownload: true }))),
CrossSpawnSpawner.defaultLayer,
),
)
describe("lsp.spawn", () => {
it.live("does not spawn builtin LSP for files outside instance", () =>
@@ -166,4 +172,28 @@ describe("lsp.spawn", () => {
{ config: { lsp: true } },
),
)
disabledDownloadIt.live("passes disableLspDownload to builtin LSP spawn", () =>
provideTmpdirInstance(
(dir) =>
LSP.Service.use((lsp) =>
Effect.gen(function* () {
const pyright = spyOn(LSPServer.Pyright, "spawn").mockResolvedValue(undefined)
try {
yield* lsp.hover({
file: path.join(dir, "src", "inside.py"),
line: 0,
character: 0,
})
expect(pyright).toHaveBeenCalledTimes(1)
expect(pyright.mock.calls[0]?.[2]).toMatchObject({ disableLspDownload: true })
} finally {
pyright.mockRestore()
}
}),
),
{ config: { lsp: true } },
),
)
})
@@ -15,10 +15,10 @@ import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { Workspace } from "../../src/control-plane/workspace"
import { Plugin } from "../../src/plugin/index"
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store"
import { Project } from "../../src/project/project"
import { Vcs } from "../../src/project/vcs"
import { InstanceState } from "../../src/effect/instance-state"
import { Session } from "../../src/session/session"
import { SessionPrompt } from "../../src/session/prompt"
import { SyncEvent } from "../../src/sync"
@@ -116,11 +116,12 @@ describe("plugin.workspace", () => {
const plugin = yield* Plugin.Service
yield* plugin.init()
const workspace = yield* Workspace.Service
const ctx = yield* InstanceState.context
const info = yield* workspace.create({
type,
branch: null,
extra: { key: "value" },
projectID: Instance.project.id,
projectID: ctx.project.id,
})
expect(info.type).toBe(type)
@@ -3,12 +3,13 @@ import { existsSync } from "node:fs"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Effect, Layer } from "effect"
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import { bootstrap as cliBootstrap } from "../../src/cli/bootstrap"
import { InstanceLayer } from "../../src/project/instance-layer"
import { InstanceStore } from "../../src/project/instance-store"
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { waitGlobalBusEvent } from "../server/global-bus"
const it = testEffect(Layer.mergeAll(InstanceLayer.layer, CrossSpawnSpawner.defaultLayer))
@@ -54,6 +55,13 @@ const bootstrapFixture = Effect.gen(function* () {
return { directory: dir, marker }
})
function waitDisposed(directory: string) {
return waitGlobalBusEvent({
message: "timed out waiting for CLI bootstrap instance disposal",
predicate: (event) => event.payload.type === "server.instance.disposed" && event.directory === directory,
})
}
it.live("InstanceStore.provide runs InstanceBootstrap before effect", () =>
Effect.gen(function* () {
const tmp = yield* bootstrapFixture
@@ -75,6 +83,21 @@ it.live("CLI bootstrap runs InstanceBootstrap before callback", () =>
}),
)
it.live("CLI bootstrap disposes the instance when the callback rejects", () =>
Effect.gen(function* () {
const tmp = yield* bootstrapFixture
const disposed = yield* waitDisposed(tmp.directory).pipe(Effect.forkScoped)
const exit = yield* Effect.promise(() =>
cliBootstrap(tmp.directory, async () => Promise.reject(new Error("boom"))),
).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toMatchObject({ message: "boom" })
yield* Fiber.join(disposed)
}),
)
it.live("InstanceStore.reload runs InstanceBootstrap", () =>
Effect.gen(function* () {
const tmp = yield* bootstrapFixture
@@ -4,9 +4,8 @@ import { Deferred, Effect, Fiber, Layer } from "effect"
import { InstanceRef } from "../../src/effect/instance-ref"
import { registerDisposer } from "../../src/effect/instance-registry"
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store"
import { TestInstance, tmpdirScoped } from "../fixture/fixture"
import { tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
let bootstrapRun: Effect.Effect<void> = Effect.void
@@ -37,7 +36,7 @@ const registerDisposerScoped = (disposer: (directory: string) => Promise<void>)
)
describe("InstanceStore", () => {
it.live("loads instance context without installing ALS for the caller", () =>
it.live("loads instance context", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
@@ -45,7 +44,6 @@ describe("InstanceStore", () => {
expect(ctx.directory).toBe(dir)
expect(ctx.worktree).toBe(dir)
expect(() => Instance.current).toThrow()
}),
)
@@ -63,7 +61,6 @@ describe("InstanceStore", () => {
yield* store.load({ directory: dir })
expect(initializedDirectory).toBe(dir)
expect(() => Instance.current).toThrow()
}),
)
@@ -245,20 +242,4 @@ describe("InstanceStore", () => {
expect(disposed).toEqual([dir1, dir2])
}),
)
it.instance(
"provides legacy Promise callers with instance ALS",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const ctx = yield* InstanceRef
if (!ctx) throw new Error("InstanceRef not provided")
const directory = yield* Effect.promise(() => Promise.resolve(Instance.restore(ctx, () => Instance.directory)))
expect(directory).toBe(test.directory)
expect(() => Instance.current).toThrow()
}),
{ git: true },
)
})
@@ -5,7 +5,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
import { Git } from "../../src/git"
import { Instance } from "../../src/project/instance"
import { InstanceRef } from "../../src/effect/instance-ref"
import { InstanceRuntime } from "../../src/project/instance-runtime"
import { Worktree } from "../../src/worktree"
import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture"
@@ -41,7 +41,10 @@ const waitReady = Effect.fn("WorktreeTest.waitReady")(function* () {
const removeCreatedWorktree = (directory: string) =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const ctx = yield* Effect.sync(() => Instance.current).pipe(provideInstance(directory))
const ctx = yield* Effect.gen(function* () {
return yield* InstanceRef
}).pipe(provideInstance(directory))
if (!ctx) return yield* Effect.die(new Error("missing test instance"))
yield* Effect.promise(() => InstanceRuntime.disposeInstance(ctx))
const ok = yield* svc.remove({ directory })
if (!ok) return yield* Effect.fail(new Error(`failed to remove worktree ${directory}`))
@@ -1,28 +1,47 @@
import { test, expect, describe } from "bun:test"
import { afterEach, test, expect, describe } from "bun:test"
import path from "path"
import { unlink } from "fs/promises"
import { ProviderID } from "../../src/provider/schema"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { disposeAllInstances, tmpdir, withTestInstance } from "../fixture/fixture"
import type { InstanceContext } from "../../src/project/instance-context"
import { Provider } from "@/provider/provider"
import { Env } from "../../src/env"
import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem"
import { Effect } from "effect"
import { AppRuntime } from "../../src/effect/app-runtime"
import { InstanceRef } from "../../src/effect/instance-ref"
import { makeRuntime } from "../../src/effect/run-service"
const env = makeRuntime(Env.Service, Env.defaultLayer)
const set = (k: string, v: string) => env.runSync((svc) => svc.set(k, v))
const originalEnv = new Map<string, string | undefined>()
async function list() {
function rememberEnv(k: string) {
if (!originalEnv.has(k)) originalEnv.set(k, process.env[k])
}
const set = (ctx: InstanceContext, k: string, v: string) => {
rememberEnv(k)
process.env[k] = v
return env.runSync((svc) => svc.set(k, v).pipe(Effect.provideService(InstanceRef, ctx)))
}
afterEach(async () => {
for (const [key, value] of originalEnv) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
originalEnv.clear()
await disposeAllInstances()
})
async function list(ctx: InstanceContext) {
return AppRuntime.runPromise(
Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* provider.list()
}),
}).pipe(Effect.provideService(InstanceRef, ctx)),
)
}
@@ -44,12 +63,12 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async ()
)
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
set("AWS_REGION", "us-east-1")
set("AWS_PROFILE", "default")
const providers = await list()
fn: async (ctx) => {
set(ctx, "AWS_REGION", "us-east-1")
set(ctx, "AWS_PROFILE", "default")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
},
@@ -67,12 +86,12 @@ test("Bedrock: falls back to AWS_REGION env var when no config region", async ()
)
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
set("AWS_REGION", "eu-west-1")
set("AWS_PROFILE", "default")
const providers = await list()
fn: async (ctx) => {
set(ctx, "AWS_REGION", "eu-west-1")
set(ctx, "AWS_PROFILE", "default")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
},
@@ -120,13 +139,13 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => {
}),
)
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
set("AWS_PROFILE", "")
set("AWS_ACCESS_KEY_ID", "")
set("AWS_BEARER_TOKEN_BEDROCK", "")
const providers = await list()
fn: async (ctx) => {
set(ctx, "AWS_PROFILE", "")
set(ctx, "AWS_ACCESS_KEY_ID", "")
set(ctx, "AWS_BEARER_TOKEN_BEDROCK", "")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
},
@@ -164,12 +183,12 @@ test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async
)
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
set("AWS_PROFILE", "default")
set("AWS_ACCESS_KEY_ID", "test-key-id")
const providers = await list()
fn: async (ctx) => {
set(ctx, "AWS_PROFILE", "default")
set(ctx, "AWS_ACCESS_KEY_ID", "test-key-id")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1")
},
@@ -194,11 +213,11 @@ test("Bedrock: includes custom endpoint in options when specified", async () =>
)
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
set("AWS_PROFILE", "default")
const providers = await list()
fn: async (ctx) => {
set(ctx, "AWS_PROFILE", "default")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.endpoint).toBe(
"https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com",
@@ -225,14 +244,14 @@ test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async ()
)
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
set("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token")
set("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role")
set("AWS_PROFILE", "")
set("AWS_ACCESS_KEY_ID", "")
const providers = await list()
fn: async (ctx) => {
set(ctx, "AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token")
set(ctx, "AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role")
set(ctx, "AWS_PROFILE", "")
set(ctx, "AWS_ACCESS_KEY_ID", "")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1")
},
@@ -266,11 +285,11 @@ test("Bedrock: model with us. prefix should not be double-prefixed", async () =>
)
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
set("AWS_PROFILE", "default")
const providers = await list()
fn: async (ctx) => {
set(ctx, "AWS_PROFILE", "default")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
// The model should exist with the us. prefix
expect(providers[ProviderID.amazonBedrock].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
@@ -301,11 +320,11 @@ test("Bedrock: model with global. prefix should not be prefixed", async () => {
)
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
set("AWS_PROFILE", "default")
const providers = await list()
fn: async (ctx) => {
set(ctx, "AWS_PROFILE", "default")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
},
@@ -335,11 +354,11 @@ test("Bedrock: model with eu. prefix should not be double-prefixed", async () =>
)
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
set("AWS_PROFILE", "default")
const providers = await list()
fn: async (ctx) => {
set(ctx, "AWS_PROFILE", "default")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
},
@@ -369,11 +388,11 @@ test("Bedrock: model without prefix in US region should get us. prefix added", a
)
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
set("AWS_PROFILE", "default")
const providers = await list()
fn: async (ctx) => {
set(ctx, "AWS_PROFILE", "default")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
// Non-prefixed model should still be registered
expect(providers[ProviderID.amazonBedrock].models["anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
@@ -7,9 +7,7 @@ export {}
// import path from "path"
// import { ProviderID, ModelID } from "../../src/provider/schema"
// import { tmpdir } from "../fixture/fixture"
// import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
// import { tmpdir, withTestInstance } from "../fixture/fixture"
// import { Provider } from "@/provider/provider"
// import { Env } from "../../src/env"
// import { Global } from "@opencode-ai/core/global"
@@ -26,7 +24,7 @@ import { WithInstance } from "../../src/project/with-instance"
// )
// },
// })
// await WithInstance.provide({
// await withTestInstance({
// directory: tmp.path,
// init: async () => {
// Env.set("GITLAB_TOKEN", "test-gitlab-token")
@@ -57,7 +55,7 @@ import { WithInstance } from "../../src/project/with-instance"
// )
// },
// })
// await WithInstance.provide({
// await withTestInstance({
// directory: tmp.path,
// init: async () => {
// Env.set("GITLAB_TOKEN", "test-token")
@@ -96,7 +94,7 @@ import { WithInstance } from "../../src/project/with-instance"
// }),
// )
// await WithInstance.provide({
// await withTestInstance({
// directory: tmp.path,
// init: async () => {
// Env.set("GITLAB_TOKEN", "")
@@ -131,7 +129,7 @@ import { WithInstance } from "../../src/project/with-instance"
// }),
// )
// await WithInstance.provide({
// await withTestInstance({
// directory: tmp.path,
// init: async () => {
// Env.set("GITLAB_TOKEN", "")
@@ -163,7 +161,7 @@ import { WithInstance } from "../../src/project/with-instance"
// )
// },
// })
// await WithInstance.provide({
// await withTestInstance({
// directory: tmp.path,
// init: async () => {
// Env.set("GITLAB_INSTANCE_URL", "https://gitlab.company.internal")
@@ -194,7 +192,7 @@ import { WithInstance } from "../../src/project/with-instance"
// )
// },
// })
// await WithInstance.provide({
// await withTestInstance({
// directory: tmp.path,
// init: async () => {
// Env.set("GITLAB_TOKEN", "env-token")
@@ -217,7 +215,7 @@ import { WithInstance } from "../../src/project/with-instance"
// )
// },
// })
// await WithInstance.provide({
// await withTestInstance({
// directory: tmp.path,
// init: async () => {
// Env.set("GITLAB_TOKEN", "test-token")
@@ -253,7 +251,7 @@ import { WithInstance } from "../../src/project/with-instance"
// )
// },
// })
// await WithInstance.provide({
// await withTestInstance({
// directory: tmp.path,
// init: async () => {
// Env.set("GITLAB_TOKEN", "test-token")
@@ -278,7 +276,7 @@ import { WithInstance } from "../../src/project/with-instance"
// )
// },
// })
// await WithInstance.provide({
// await withTestInstance({
// directory: tmp.path,
// init: async () => {
// Env.set("GITLAB_TOKEN", "test-token")
@@ -302,7 +300,7 @@ import { WithInstance } from "../../src/project/with-instance"
// await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
// },
// })
// await WithInstance.provide({
// await withTestInstance({
// directory: tmp.path,
// init: async () => {
// Env.set("GITLAB_TOKEN", "test-token")
@@ -350,7 +348,7 @@ import { WithInstance } from "../../src/project/with-instance"
// await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
// },
// })
// await WithInstance.provide({
// await withTestInstance({
// directory: tmp.path,
// init: async () => {
// Env.set("GITLAB_TOKEN", "test-token")
@@ -373,7 +371,7 @@ import { WithInstance } from "../../src/project/with-instance"
// await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
// },
// })
// await WithInstance.provide({
// await withTestInstance({
// directory: tmp.path,
// init: async () => {
// Env.set("GITLAB_TOKEN", "test-token")
@@ -397,7 +395,7 @@ import { WithInstance } from "../../src/project/with-instance"
// await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
// },
// })
// await WithInstance.provide({
// await withTestInstance({
// directory: tmp.path,
// init: async () => {
// Env.set("GITLAB_TOKEN", "test-token")
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
import { afterEach, expect } from "bun:test"
import { Cause, Effect, Exit, Fiber, Layer, Queue } from "effect"
import { Question } from "../../src/question"
import { Instance } from "../../src/project/instance"
import { InstanceRef } from "../../src/effect/instance-ref"
import { InstanceRuntime } from "../../src/project/instance-runtime"
import { QuestionID } from "../../src/question/schema"
import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture"
@@ -404,7 +404,10 @@ it.live("pending question rejects on instance dispose", () =>
}).pipe(provideInstance(dir), Effect.forkScoped)
expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1)
const ctx = yield* Effect.sync(() => Instance.current).pipe(provideInstance(dir))
const ctx = yield* Effect.gen(function* () {
return yield* InstanceRef
}).pipe(provideInstance(dir))
if (!ctx) return yield* Effect.die(new Error("missing test instance"))
yield* Effect.promise(() => InstanceRuntime.disposeInstance(ctx))
const exit = yield* Fiber.await(fiber)
@@ -1,11 +1,12 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Bus } from "../../src/bus"
import { Instance } from "../../src/project/instance"
import { AppRuntime } from "../../src/effect/app-runtime"
import { InstanceRef } from "../../src/effect/instance-ref"
import { Server } from "../../src/server/server"
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
import { Event as ServerEvent } from "../../src/server/event"
import * as Log from "@opencode-ai/core/util/log"
import { Schema } from "effect"
import { Effect, Schema } from "effect"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, reloadTestInstance, tmpdir } from "../fixture/fixture"
@@ -108,7 +109,9 @@ describe("event HttpApi", () => {
const next = readEvent(reader)
const ctx = await reloadTestInstance({ directory: tmp.path })
await Instance.restore(ctx, () => Bus.publish(ServerEvent.Connected, {}))
await AppRuntime.runPromise(
Bus.Service.use((svc) => svc.publish(ServerEvent.Connected, {})).pipe(Effect.provideService(InstanceRef, ctx)),
)
expect(await next).toMatchObject({ type: "server.connected", properties: {} })
} finally {
@@ -3,7 +3,6 @@ export type Runtime = {
HttpApiApp: (typeof import("../../../src/server/routes/instance/httpapi/server"))["HttpApiApp"]
AppLayer: (typeof import("../../../src/effect/app-runtime"))["AppLayer"]
InstanceRef: (typeof import("../../../src/effect/instance-ref"))["InstanceRef"]
Instance: (typeof import("../../../src/project/instance"))["Instance"]
InstanceStore: (typeof import("../../../src/project/instance-store"))["InstanceStore"]
Session: (typeof import("../../../src/session/session"))["Session"]
Todo: (typeof import("../../../src/session/todo"))["Todo"]
@@ -23,7 +22,6 @@ export function runtime() {
const httpApiServer = await import("../../../src/server/routes/instance/httpapi/server")
const appRuntime = await import("../../../src/effect/app-runtime")
const instanceRef = await import("../../../src/effect/instance-ref")
const instance = await import("../../../src/project/instance")
const instanceStore = await import("../../../src/project/instance-store")
const session = await import("../../../src/session/session")
const todo = await import("../../../src/session/todo")
@@ -37,7 +35,6 @@ export function runtime() {
HttpApiApp: httpApiServer.HttpApiApp,
AppLayer: appRuntime.AppLayer,
InstanceRef: instanceRef.InstanceRef,
Instance: instance.Instance,
InstanceStore: instanceStore.InstanceStore,
Session: session.Session,
Todo: todo.Todo,
@@ -3,7 +3,6 @@ import { Context } from "effect"
import path from "path"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
import { Instance } from "../../src/project/instance"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
@@ -10,7 +10,6 @@ import { WorkspaceID } from "../../src/control-plane/schema"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
import { Instance } from "../../src/project/instance"
import { InstanceLayer } from "../../src/project/instance-layer"
import { Project } from "../../src/project/project"
import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
@@ -1,7 +1,6 @@
import { afterEach, describe, expect, test } from "bun:test"
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { PtyID } from "../../src/pty/schema"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import * as Log from "@opencode-ai/core/util/log"
@@ -1,7 +1,6 @@
import { afterEach, describe, expect, test } from "bun:test"
import { ConfigProvider, Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { Instance } from "../../src/project/instance"
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
@@ -13,7 +13,6 @@ import * as Log from "@opencode-ai/core/util/log"
import { Server } from "../../src/server/server"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { InstanceBootstrap } from "../../src/project/bootstrap"
import { InstanceStore } from "../../src/project/instance-store"
import { Project } from "../../src/project/project"
@@ -71,7 +70,7 @@ function listedAdapter(directory: string, type: string): WorkspaceAdapter {
},
async create() {},
async remove() {},
list() {
list(context) {
return [
{
type,
@@ -79,7 +78,7 @@ function listedAdapter(directory: string, type: string): WorkspaceAdapter {
branch: "listed/main",
directory,
extra: { listed: true },
projectID: Instance.project.id,
projectID: context?.instance?.project.id ?? missingAdapterContext(),
},
]
},
@@ -92,6 +91,10 @@ function listedAdapter(directory: string, type: string): WorkspaceAdapter {
}
}
function missingAdapterContext(): never {
throw new Error("missing workspace adapter context")
}
function remoteAdapter(directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter {
return {
name: "Remote Test",
@@ -10,6 +10,7 @@ import { Instruction } from "../../src/session/instruction"
import type { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { Global } from "@opencode-ai/core/global"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { TestConfig } from "../fixture/config"
@@ -18,18 +19,19 @@ const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSys
const configLayer = TestConfig.layer()
const instructionLayer = (global: Partial<Global.Interface>) =>
const instructionLayer = (global: Partial<Global.Interface>, flags: Partial<RuntimeFlags.Info> = {}) =>
Instruction.layer.pipe(
Layer.provide(configLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(Global.layerWith(global)),
Layer.provide(RuntimeFlags.layer(flags)),
)
const provideInstruction =
(global: Partial<Global.Interface>) =>
(global: Partial<Global.Interface>, flags?: Partial<RuntimeFlags.Info>) =>
<A, E, R>(self: Effect.Effect<A, E, R>) =>
self.pipe(Effect.provide(instructionLayer(global)))
self.pipe(Effect.provide(instructionLayer(global, flags)))
const write = (filepath: string, content: string) =>
Effect.gen(function* () {
@@ -215,6 +217,24 @@ describe("Instruction.system", () => {
}).pipe(provideInstance(projectTmp), provideInstruction({ home: globalTmp, config: globalTmp }))
}),
)
it.live("skips project and global CLAUDE.md when Claude Code prompt is disabled", () =>
Effect.gen(function* () {
const globalTmp = yield* tmpWithFiles({ ".claude/CLAUDE.md": "# Global Claude" })
const projectTmp = yield* tmpWithFiles({ "CLAUDE.md": "# Project Claude" })
yield* Effect.gen(function* () {
const svc = yield* Instruction.Service
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(globalTmp, ".claude", "CLAUDE.md"))).toBe(false)
expect(paths.has(path.join(projectTmp, "CLAUDE.md"))).toBe(false)
expect(yield* svc.system()).toEqual([])
}).pipe(
provideInstance(projectTmp),
provideInstruction({ home: globalTmp, config: globalTmp }, { disableClaudeCodePrompt: true }),
)
}),
)
})
describe("Instruction.systemPaths global config", () => {
+155 -132
View File
@@ -4,33 +4,35 @@ import { tool, type ModelMessage } from "ai"
import { Cause, Effect, Exit, Stream } from "effect"
import z from "zod"
import { makeRuntime } from "../../src/effect/run-service"
import { InstanceRef } from "../../src/effect/instance-ref"
import { LLM } from "../../src/session/llm"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import type { InstanceContext } from "../../src/project/instance-context"
import { Provider } from "@/provider/provider"
import { ProviderTransform } from "@/provider/transform"
import { ModelsDev } from "@opencode-ai/core/models"
import { ProviderID, ModelID } from "../../src/provider/schema"
import { Filesystem } from "@/util/filesystem"
import { tmpdir } from "../fixture/fixture"
import { tmpdir, withTestInstance } from "../fixture/fixture"
import type { Agent } from "../../src/agent/agent"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionID, MessageID } from "../../src/session/schema"
import { AppRuntime } from "../../src/effect/app-runtime"
async function getModel(providerID: ProviderID, modelID: ModelID) {
return AppRuntime.runPromise(
Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* provider.getModel(providerID, modelID)
}),
)
async function getModel(providerID: ProviderID, modelID: ModelID, ctx: InstanceContext) {
const effect = Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* provider.getModel(providerID, modelID)
})
return AppRuntime.runPromise(effect.pipe(Effect.provideService(InstanceRef, ctx)))
}
const llm = makeRuntime(LLM.Service, LLM.defaultLayer)
async function drain(input: LLM.StreamInput) {
return llm.runPromise((svc) => svc.stream(input).pipe(Stream.runDrain))
async function drain(input: LLM.StreamInput, ctx: InstanceContext) {
return llm.runPromise((svc) => {
const effect = svc.stream(input).pipe(Stream.runDrain)
return effect.pipe(Effect.provideService(InstanceRef, ctx))
})
}
describe("session.llm.hasToolCalls", () => {
@@ -358,10 +360,10 @@ describe("session.llm.stream", () => {
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
fn: async (ctx) => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx)
const sessionID = SessionID.make("session-test-1")
const agent = {
name: "test",
@@ -381,15 +383,18 @@ describe("session.llm.stream", () => {
model: { providerID: ProviderID.make(providerID), modelID: resolved.id, variant: "high" },
} satisfies MessageV2.User
await drain({
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
messages: [{ role: "user", content: "Hello" }],
tools: {},
})
await drain(
{
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
messages: [{ role: "user", content: "Hello" }],
tools: {},
},
ctx,
)
const capture = await request
const body = capture.body
@@ -445,10 +450,10 @@ describe("session.llm.stream", () => {
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
fn: async (ctx) => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx)
const sessionID = SessionID.make("session-test-service-abort")
const agent = {
name: "test",
@@ -478,7 +483,7 @@ describe("session.llm.stream", () => {
messages: [{ role: "user", content: "Hello" }],
tools: {},
})
.pipe(Stream.runDrain),
.pipe(Stream.runDrain, Effect.provideService(InstanceRef, ctx)),
{ signal: ctrl.signal },
)
@@ -535,10 +540,10 @@ describe("session.llm.stream", () => {
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
fn: async (ctx) => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx)
const sessionID = SessionID.make("session-test-tools")
const agent = {
name: "test",
@@ -557,22 +562,25 @@ describe("session.llm.stream", () => {
tools: { question: true },
} satisfies MessageV2.User
await drain({
user,
sessionID,
model: resolved,
agent,
permission: [{ permission: "question", pattern: "*", action: "allow" }],
system: ["You are a helpful assistant."],
messages: [{ role: "user", content: "Hello" }],
tools: {
question: tool({
description: "Ask a question",
inputSchema: z.object({}),
execute: async () => ({ output: "" }),
}),
await drain(
{
user,
sessionID,
model: resolved,
agent,
permission: [{ permission: "question", pattern: "*", action: "allow" }],
system: ["You are a helpful assistant."],
messages: [{ role: "user", content: "Hello" }],
tools: {
question: tool({
description: "Ask a question",
inputSchema: z.object({}),
execute: async () => ({ output: "" }),
}),
},
},
})
ctx,
)
const capture = await request
const tools = capture.body.tools as Array<{ function?: { name?: string } }> | undefined
@@ -649,10 +657,10 @@ describe("session.llm.stream", () => {
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id))
fn: async (ctx) => {
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id), ctx)
const sessionID = SessionID.make("session-test-2")
const agent = {
name: "test",
@@ -671,15 +679,18 @@ describe("session.llm.stream", () => {
model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" },
} satisfies MessageV2.User
await drain({
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
messages: [{ role: "user", content: "Hello" }],
tools: {},
})
await drain(
{
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
messages: [{ role: "user", content: "Hello" }],
tools: {},
},
ctx,
)
const capture = await request
const body = capture.body
@@ -765,10 +776,10 @@ describe("session.llm.stream", () => {
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id))
fn: async (ctx) => {
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id), ctx)
const sessionID = SessionID.make("session-test-data-url")
const agent = {
name: "test",
@@ -786,28 +797,31 @@ describe("session.llm.stream", () => {
model: { providerID: ProviderID.make("openai"), modelID: resolved.id },
} satisfies MessageV2.User
await drain({
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
messages: [
{
role: "user",
content: [
{ type: "text", text: "Describe this image" },
{
type: "file",
mediaType: "image/png",
filename: "large-image.png",
data: image,
},
],
},
] as ModelMessage[],
tools: {},
})
await drain(
{
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
messages: [
{
role: "user",
content: [
{ type: "text", text: "Describe this image" },
{
type: "file",
mediaType: "image/png",
filename: "large-image.png",
data: image,
},
],
},
] as ModelMessage[],
tools: {},
},
ctx,
)
const capture = await request
expect(capture.url.pathname.endsWith("/responses")).toBe(true)
@@ -884,10 +898,10 @@ describe("session.llm.stream", () => {
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
fn: async (ctx) => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx)
const sessionID = SessionID.make("session-test-3")
const agent = {
name: "test",
@@ -907,15 +921,18 @@ describe("session.llm.stream", () => {
model: { providerID: ProviderID.make("minimax"), modelID: ModelID.make("MiniMax-M2.5") },
} satisfies MessageV2.User
await drain({
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
messages: [{ role: "user", content: "Hello" }],
tools: {},
})
await drain(
{
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
messages: [{ role: "user", content: "Hello" }],
tools: {},
},
ctx,
)
const capture = await request
const body = capture.body
@@ -1002,10 +1019,10 @@ describe("session.llm.stream", () => {
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const resolved = await getModel(ProviderID.make("anthropic"), ModelID.make(model.id))
fn: async (ctx) => {
const resolved = await getModel(ProviderID.make("anthropic"), ModelID.make(model.id), ctx)
const sessionID = SessionID.make("session-test-anthropic-tools")
const agent = {
name: "test",
@@ -1110,31 +1127,34 @@ describe("session.llm.stream", () => {
},
] as any[]
await drain({
user,
sessionID,
model: resolved,
agent,
system: [],
messages: await MessageV2.toModelMessages(input as any, resolved),
tools: {
read: tool({
description: "Stub read tool",
inputSchema: z.object({
filePath: z.string(),
await drain(
{
user,
sessionID,
model: resolved,
agent,
system: [],
messages: await MessageV2.toModelMessages(input as any, resolved),
tools: {
read: tool({
description: "Stub read tool",
inputSchema: z.object({
filePath: z.string(),
}),
execute: async () => ({ output: "stub" }),
}),
execute: async () => ({ output: "stub" }),
}),
glob: tool({
description: "Stub glob tool",
inputSchema: z.object({
pattern: z.string(),
path: z.string().optional(),
glob: tool({
description: "Stub glob tool",
inputSchema: z.object({
pattern: z.string(),
path: z.string().optional(),
}),
execute: async () => ({ output: "stub" }),
}),
execute: async () => ({ output: "stub" }),
}),
},
},
})
ctx,
)
const capture = await request
const body = capture.body
@@ -1243,10 +1263,10 @@ describe("session.llm.stream", () => {
},
})
await WithInstance.provide({
await withTestInstance({
directory: tmp.path,
fn: async () => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
fn: async (ctx) => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx)
const sessionID = SessionID.make("session-test-4")
const agent = {
name: "test",
@@ -1266,15 +1286,18 @@ describe("session.llm.stream", () => {
model: { providerID: ProviderID.make(providerID), modelID: resolved.id },
} satisfies MessageV2.User
await drain({
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
messages: [{ role: "user", content: "Hello" }],
tools: {},
})
await drain(
{
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
messages: [{ role: "user", content: "Hello" }],
tools: {},
},
ctx,
)
const capture = await request
const body = capture.body
@@ -29,6 +29,19 @@ const itWithoutClaudeCodeSkills = testEffect(
node,
),
)
const itWithoutExternalSkills = testEffect(
Layer.mergeAll(
Skill.layer.pipe(
Layer.provide(Discovery.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Global.layer),
Layer.provide(RuntimeFlags.layer({ disableExternalSkills: true })),
),
node,
),
)
async function createGlobalSkill(homeDir: string) {
const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill")
@@ -420,6 +433,53 @@ description: A skill in the .agents/skills directory.
),
)
itWithoutExternalSkills.live("skips external skill directories when disabled", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Promise.all([
Bun.write(
path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
`---
name: claude-skill
description: A skill in the .claude/skills directory.
---
# Claude Skill
`,
),
Bun.write(
path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
`---
name: agent-skill
description: A skill in the .agents/skills directory.
---
# Agent Skill
`,
),
Bun.write(
path.join(dir, ".opencode", "skill", "opencode-skill", "SKILL.md"),
`---
name: opencode-skill
description: A skill in the .opencode/skill directory.
---
# OpenCode Skill
`,
),
]),
)
const skill = yield* Skill.Service
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
expect(list.map((s) => s.name)).toEqual(["opencode-skill"])
}),
{ git: true },
),
)
it.live("properly resolves directories that skills live in", () =>
provideTmpdirInstance(
(dir) =>
@@ -26,4 +26,13 @@ describe("Database.getChannelPath", () => {
expect(Database.getChannelPath(flags)).toBe(path.join(Global.Path.data, "opencode.db"))
}).pipe(Effect.provide(RuntimeFlags.layer({ disableChannelDb: true }))),
)
it.effect("accepts RuntimeFlags with skipMigrations for database callers", () =>
Effect.gen(function* () {
const flags = yield* RuntimeFlags.Service
expect(flags.skipMigrations).toBe(true)
expect(Database.getChannelPath(flags)).toBe(Database.getChannelPath({ disableChannelDb: flags.disableChannelDb }))
}).pipe(Effect.provide(RuntimeFlags.layer({ skipMigrations: true }))),
)
})
@@ -48,7 +48,7 @@ describe("tool.assertExternalDirectory", () => {
}),
)
it.live("no-ops for paths inside Instance.directory", () =>
it.live("no-ops for paths inside the instance directory", () =>
provideInstance("/tmp/project")(
Effect.gen(function* () {
const { requests, ctx } = makeCtx()
-1
View File
@@ -6,7 +6,6 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { LSP } from "@/lsp/lsp"
import { Permission } from "../../src/permission"
import { Instance } from "../../src/project/instance"
import { MessageID, SessionID } from "../../src/session/schema"
import { Tool } from "@/tool/tool"
import { Truncate } from "@/tool/truncate"
-1
View File
@@ -10,7 +10,6 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
import { Git } from "@/git"
import { LSP } from "@/lsp/lsp"
import { Permission } from "../../src/permission"
import { Instance } from "../../src/project/instance"
import { SessionID, MessageID } from "../../src/session/schema"
import { Instruction } from "../../src/session/instruction"
import { ReadTool } from "../../src/tool/read"
+68 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { pathToFileURL } from "url"
import { fileURLToPath, pathToFileURL } from "url"
import { Effect, Layer, Result, Schema } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { ToolRegistry } from "@/tool/registry"
@@ -263,6 +263,73 @@ describe("tool.registry", () => {
}),
)
it.instance(
"preserves Zod arg descriptions from older config-scoped plugin packages",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const opencode = path.join(test.directory, ".opencode")
const customTools = path.join(opencode, "tools")
const plugin = path.join(opencode, "node_modules", "@opencode-ai", "plugin")
yield* Effect.promise(() => fs.mkdir(path.join(plugin, "dist"), { recursive: true }))
yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
yield* Effect.promise(() =>
fs.cp(path.dirname(fileURLToPath(import.meta.resolve("zod"))), path.join(opencode, "node_modules", "zod"), {
dereference: true,
recursive: true,
}),
)
yield* Effect.promise(() =>
Bun.write(
path.join(plugin, "package.json"),
JSON.stringify({ name: "@opencode-ai/plugin", type: "module", exports: { ".": "./dist/index.js" } }),
),
)
yield* Effect.promise(() =>
Bun.write(
path.join(plugin, "dist", "index.js"),
[
"import { z } from 'zod'",
"export function tool(input) {",
" return input",
"}",
"tool.schema = z",
"",
].join("\n"),
),
)
yield* Effect.promise(() =>
Bun.write(
path.join(customTools, "addition.ts"),
[
'import { tool } from "@opencode-ai/plugin"',
"export default tool({",
" description: 'Use this tool to add two numbers and return their sum.',",
" args: {",
" left: tool.schema.number().describe('The first number to add'),",
" right: tool.schema.number().describe('The second number to add'),",
" },",
" execute: async (args) => `${args.left} + ${args.right} = ${args.left + args.right}`,",
"})",
"",
].join("\n"),
),
)
const registry = yield* ToolRegistry.Service
const loaded = (yield* registry.all()).find((tool) => tool.id === "addition")
if (!loaded) throw new Error("custom addition tool was not loaded")
expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({
properties: {
left: { type: "number", description: "The first number to add" },
right: { type: "number", description: "The second number to add" },
},
})
}),
20_000,
)
it.instance("preserves attachments from structured custom tool results", () =>
Effect.gen(function* () {
const test = yield* TestInstance
@@ -97,6 +97,21 @@ describe("tool.repo_overview", () => {
),
)
it.live("resolves relative paths from the instance directory", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
yield* fs.writeWithDirs(path.join(dir, "nested", "README.md"), "# Nested\n")
const tool = yield* init()
const result = yield* tool.execute({ path: "nested" }, ctx)
expect(result.metadata.path).toBe(path.join(dir, "nested"))
expect(result.output).toContain("README.md")
}),
),
)
it.live("resolves a cached repository from repository shorthand", () =>
provideTmpdirInstance((_dir) =>
Effect.gen(function* () {
@@ -5,7 +5,6 @@ import path from "path"
import { pathToFileURL } from "url"
import type { Permission } from "../../src/permission"
import type { Tool } from "@/tool/tool"
import { Instance } from "../../src/project/instance"
import { SkillTool } from "../../src/tool/skill"
import { ToolRegistry } from "@/tool/registry"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
@@ -3,7 +3,6 @@ import { Effect, Layer } from "effect"
import path from "path"
import fs from "fs/promises"
import { WriteTool } from "../../src/tool/write"
import { Instance } from "../../src/project/instance"
import { LSP } from "@/lsp/lsp"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Bus } from "../../src/bus"

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