Compare commits

..
Author SHA1 Message Date
Kit Langton 8157edeaa1 test(httpapi): simplify event stream regression coverage 2026-05-13 22:06:00 -04:00
Kit Langton c4da7b5dc6 fix(httpapi): preserve event stream context 2026-05-13 21:41:49 -04:00
Aiden ClineandJames Long 89c51a86bd fix(httpapi): provide instance context to event stream subscription
The /event SSE handler called bus.subscribeAll() which evaluates its
inner Effect (InstanceState.get → InstanceRef/Instance.current lookup)
inside the body-stream consumer fiber. That fiber does not carry the
request handler's ALS/Effect context, so the lookup failed and the
stream halted right after server.connected — no message deltas,
permission asks, or heartbeats could reach clients.

Capture InstanceState.context and InstanceState.workspaceID at handler
time and provide them to the subscription stream via Stream.provideService.

Fixes #27391.

Co-authored-by: James Long <longster@gmail.com>
2026-05-13 20:16:20 -05:00
289 changed files with 4225 additions and 8723 deletions
-50
View File
@@ -1,50 +0,0 @@
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
@@ -0,0 +1,235 @@
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(`=============================`)
+32 -32
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.15.0",
"version": "1.14.49",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -84,7 +84,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.15.0",
"version": "1.14.49",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -119,7 +119,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.15.0",
"version": "1.14.49",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -146,7 +146,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.15.0",
"version": "1.14.49",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.15.0",
"version": "1.14.49",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.15.0",
"version": "1.14.49",
"bin": {
"opencode": "./bin/opencode",
},
@@ -253,7 +253,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.15.0",
"version": "1.14.49",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -307,7 +307,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.15.0",
"version": "1.14.49",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -337,7 +337,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.15.0",
"version": "1.14.49",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -353,7 +353,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.15.0",
"version": "1.14.49",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -366,7 +366,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.15.0",
"version": "1.14.49",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -384,7 +384,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.15.0",
"version": "1.14.49",
"bin": {
"opencode": "./bin/opencode",
},
@@ -520,7 +520,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.15.0",
"version": "1.14.49",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -536,9 +536,9 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.2.10",
"@opentui/keymap": ">=0.2.10",
"@opentui/solid": ">=0.2.10",
"@opentui/core": ">=0.2.9",
"@opentui/keymap": ">=0.2.9",
"@opentui/solid": ">=0.2.9",
},
"optionalPeers": [
"@opentui/core",
@@ -558,7 +558,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.15.0",
"version": "1.14.49",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -573,7 +573,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.15.0",
"version": "1.14.49",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -608,7 +608,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.15.0",
"version": "1.14.49",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -657,7 +657,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.15.0",
"version": "1.14.49",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -721,9 +721,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.2.10",
"@opentui/keymap": "0.2.10",
"@opentui/solid": "0.2.10",
"@opentui/core": "0.2.9",
"@opentui/keymap": "0.2.9",
"@opentui/solid": "0.2.9",
"@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -1590,23 +1590,23 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.2.10", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.10", "@opentui/core-darwin-x64": "0.2.10", "@opentui/core-linux-arm64": "0.2.10", "@opentui/core-linux-x64": "0.2.10", "@opentui/core-win32-arm64": "0.2.10", "@opentui/core-win32-x64": "0.2.10" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-oviCtx0jYjc7F8X2b8+0IkQLg6WH47Nwl6CFeZo5dU0k6OpSbTbi07ZleObaiECAp+S1YLhAtVdgzHU7hBZlaw=="],
"@opentui/core": ["@opentui/core@0.2.9", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.9", "@opentui/core-darwin-x64": "0.2.9", "@opentui/core-linux-arm64": "0.2.9", "@opentui/core-linux-x64": "0.2.9", "@opentui/core-win32-arm64": "0.2.9", "@opentui/core-win32-x64": "0.2.9" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Kmeqi+yiDau+P45xDeX08GS50FK917qVwuPTN7HGxsQ9Byt7Iifq/6OMiSnFULBzoZtECdKLgQF1XwLsNm1wig=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+lbDDj42Og+UtTZEwlHhGXichmOlkxSqn0J+Jqjat5/Tt5oZykj1NZjFIQ7ZSz4Miz7EmZwgYKE2CyOmmm9MoQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-D2ne8Xgyrg71L/9lF7vPh30Sxz6+3yAqpT0m87WiI+040J7sQEyK3YM/7w5JKuVemQ4H54HSPjofrUHjfibjoQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-5iAoA0aqMWWAQ93nh8Bb0ipwt9h+tvEFc88+YO9St43uUJ+XrXcmMj3T8wtl6dSu/SN0UoDWNaUMHUmtykiPtg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ymbbt/wN/vgB8g+kbHospJclVKHq6cdgfEYg9qgsSHp2vqMFBqlQQ692MS3BcZfX9jrKROK7NvC6Hj37X5K/7Q=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-EnrkxgH5K76Oi/Br1UHPZblXG5P60snmtySfnxuVaeECNZrbTkV6BV/A0WoBeWshJweGbx1D+eTF+sEEjQCi8w=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-0RIrVe4+42oELHtSJBaaYhngUeMKwSeqfdtKeSwEFwCzrqrNXxCpXQdOo8QvjOKGgng4Smn6O6KM8sgCj4SSPQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.10", "", { "os": "linux", "cpu": "x64" }, "sha512-fI+r3kCPqIxsWwPVGpKUQy4zHK8y+jkDRCwa3UbaUy48RQ44jMuf2RhVhmi4xmCvSc8UPJBbYsw1tLuh9kmXjg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-fjCZP1IOLWm68FYl2PRzFg1vfu226FPfiJsdNtLbhaYF2uEZOB/v1BQph21OKnB7GC7X8GQatvhM5sS3DQ2MSQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-8F4z2hIRgkVWcr6CMVeJ9N4+1rmURPt2Pq2GBPko8ch6rxHR+a//KD1MfphyuLTHBS1tJ4vfZSWSoiaESImtrA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-u8SP3u2QEJqcGIULYZ7Lkht9ss7wcN4/LnMuqt9rPOiCduFn/VW4r8lQCftZ6DRSqyoP9mJ1xLzOSFl98UYyEw=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.10", "", { "os": "win32", "cpu": "x64" }, "sha512-Ki+qNBlIFW5K2wcG/RHrlPp7yEQKXeiNX3mlje25iwX62Ac5w391HBpOmUjbPoq20McPyDRnhbLfbXQSPtickg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-un7iSy9XHLwa6ouVpUj3eEGnXfPG50OMUJ2Dt30Jvn2vhNwIU2VO4RGx06l5OUD6GGVpHb0RqmG/384oo9i+HA=="],
"@opentui/keymap": ["@opentui/keymap@0.2.10", "", { "dependencies": { "@opentui/core": "0.2.10" }, "peerDependencies": { "@opentui/react": "0.2.10", "@opentui/solid": "0.2.10", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-80fU3Lr/98sNIpVYd8PApAeQw8A8D9BemyOGi6jGvTQCl0rxKgvaVBviDRGKxl1INTVjZy9By8UPncc2KJOuWQ=="],
"@opentui/keymap": ["@opentui/keymap@0.2.9", "", { "dependencies": { "@opentui/core": "0.2.9" }, "peerDependencies": { "@opentui/react": "0.2.9", "@opentui/solid": "0.2.9", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-yCc6L0Jqa8aVaNAVniTV5bNygJayUE6mxWfaBQY5VV5QwsZemXSeQQc4vP2eetH4Rrm1gGA59gLP+zh6+s5fvw=="],
"@opentui/solid": ["@opentui/solid@0.2.10", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.10", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-+4/MB90yIQiPwg8Y4wY092yva9BvRTsJeeeEO3e2H7P8k8zxYk4G9bzuhqYLxA9mTVQ+zVDlrmFoPQhT7vpIRw=="],
"@opentui/solid": ["@opentui/solid@0.2.9", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.9", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-qpNSCELxRvBAx8Zneqz46FYYTvJNFjDvhqzAAZRNoaHathfU6X6iPxWMUqP/9ls5VcHFW1TDJdgtpsq1N/nHMQ=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
+26 -10
View File
@@ -70,10 +70,11 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => {
}).json
}
const providerHttpErrorsQuery = () => {
const providerHttpErrorsQuery = (product: "go" | "zen") => {
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",
@@ -100,15 +101,11 @@ const providerHttpErrorsQuery = () => {
name: "FAILED",
column: failedProviderHttpStatus.name,
filterCombination: "AND",
filters: [
...filters,
{ column: "event_type", op: "=", value: "llm.error" },
{ column: "llm.error.code", op: "!=", value: "404" },
],
filters: [...filters, { column: "event_type", op: "=", value: "llm.error" }],
},
],
formulas: [
{ name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 200), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" },
{ name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 50), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" },
],
timeRange: 900,
}).json
@@ -218,10 +215,29 @@ new honeycomb.Trigger("LowModelTpsZen", {
],
})
new honeycomb.Trigger("IncreasedProviderHttpErrors", {
name: "Increased Provider HTTP Errors",
new honeycomb.Trigger("IncreasedProviderHttpErrorsGo", {
name: "Increased Provider HTTP Errors [Go]",
description,
queryJson: providerHttpErrorsQuery(),
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"),
alertType: "on_change",
frequency: 300,
thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Hw7sVV9rTm6qBMtdwfLIV2QvxvLQY5qrywXzuyYbhcs=",
"aarch64-linux": "sha256-++oXnY7YqrYt0Qv7ZISmoHliARM9qEP8FacqLxGZH1c=",
"aarch64-darwin": "sha256-kZVa0R1YbuvtTzpETqK6ddj4ISje5jBFHBdlynkhW7Q=",
"x86_64-darwin": "sha256-94eagNDa8GGJxF8BsMX2BF5Pa+QTl48lXL1+6HgEn0I="
"x86_64-linux": "sha256-QIj9PhOXR/GngV/dPjCF7n5rKro2fcTNGzJ47a41Z2Q=",
"aarch64-linux": "sha256-fQl7BjjTYtRKT3HRVhubaIVww/puUFSTzVV5bTy8II8=",
"aarch64-darwin": "sha256-81IAmdjiYZz8IgMJt0+VxzdOS80gTHc5SendwEW/vD4=",
"x86_64-darwin": "sha256-5OMX4VVBMfEmkYvzd09oksAt5hKkxDs84miO804LBI8="
}
}
+3 -3
View File
@@ -35,9 +35,9 @@
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.10",
"@opentui/keymap": "0.2.10",
"@opentui/solid": "0.2.10",
"@opentui/core": "0.2.9",
"@opentui/keymap": "0.2.9",
"@opentui/solid": "0.2.9",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.15.0",
"version": "1.14.49",
"description": "",
"type": "module",
"exports": {
@@ -13,7 +13,6 @@ const statusLabels = {
connected: "mcp.status.connected",
failed: "mcp.status.failed",
needs_auth: "mcp.status.needs_auth",
needs_client_registration: "mcp.status.needs_client_registration",
disabled: "mcp.status.disabled",
} as const
@@ -32,16 +31,8 @@ export const DialogSelectMcp: Component = () => {
const toggle = useMutation(() => ({
mutationFn: async (name: string) => {
const status = sync.data.mcp[name]
if (status?.status === "connected") {
await sdk.client.mcp.disconnect({ name })
return
}
if (status?.status === "needs_auth") {
await sdk.client.mcp.auth.authenticate({ name })
return
}
await sdk.client.mcp.connect({ name })
if (sync.data.mcp[name]?.status === "connected") await sdk.client.mcp.disconnect({ name })
else await sdk.client.mcp.connect({ name })
},
onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))),
}))
@@ -76,7 +67,7 @@ export const DialogSelectMcp: Component = () => {
}
const error = () => {
const s = mcpStatus()
if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error
return s?.status === "failed" ? s.error : undefined
}
const enabled = () => status() === "connected"
return (
@@ -87,6 +78,9 @@ export const DialogSelectMcp: Component = () => {
<Show when={statusLabel()}>
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
</Show>
<Show when={toggle.isPending && toggle.variables === i.name}>
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
</Show>
</div>
<Show when={error()}>
<span class="text-11-regular text-text-weaker truncate">{error()}</span>
@@ -145,15 +145,7 @@ const useMcpToggleMutation = () => {
return useMutation(() => ({
mutationFn: async (name: string) => {
const status = sync.data.mcp[name]
if (status?.status === "connected") {
await sdk.client.mcp.disconnect({ name })
return
}
if (status?.status === "needs_auth") {
await sdk.client.mcp.auth.authenticate({ name })
return
}
await sdk.client.mcp.connect({ name })
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
},
onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))),
onError: (err) => {
@@ -324,7 +316,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
return (
<button
type="button"
class="flex items-center gap-2 w-full min-h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name)
@@ -341,16 +333,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
status() === "needs_auth" || status() === "needs_client_registration",
}}
/>
<span class="flex flex-col min-w-0 flex-1">
<span class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-base truncate">{name}</span>
</span>
<Show when={status() === "needs_auth"}>
<span class="text-11-regular text-text-weaker truncate">
{language.t("mcp.auth.clickToAuthenticate")}
</span>
</Show>
</span>
<span class="text-14-regular text-text-base truncate flex-1">{name}</span>
<div onClick={(event) => event.stopPropagation()}>
<Switch
checked={enabled()}
@@ -14,14 +14,12 @@ export function StatusPopover() {
const sync = useSync()
const [shown, setShown] = createSignal(false)
const ready = createMemo(() => server.healthy() === false || sync.data.mcp_ready)
const mcpIssue = createMemo(() => {
const healthy = createMemo(() => {
const serverHealthy = server.healthy() === true
const mcp = Object.values(sync.data.mcp ?? {})
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
const warn = mcp.some((item) => item.status === "needs_auth")
if (failed) return "critical" as const
if (warn) return "warning" as const
const issue = mcp.some((item) => item.status !== "connected" && item.status !== "disabled")
return serverHealthy && !issue
})
const healthy = createMemo(() => server.healthy() === true && !mcpIssue())
return (
<Popover
@@ -43,9 +41,7 @@ export function StatusPopover() {
classList={{
"absolute -top-px -right-px size-1.5 rounded-full": true,
"bg-icon-success-base": ready() && healthy(),
"bg-icon-warning-base": ready() && server.healthy() === true && mcpIssue() === "warning",
"bg-icon-critical-base":
server.healthy() === false || (ready() && server.healthy() === true && mcpIssue() === "critical"),
"bg-icon-critical-base": server.healthy() === false || (ready() && !healthy()),
"bg-border-weak-base": server.healthy() === undefined || !ready(),
}}
/>
@@ -125,7 +125,6 @@ export function applyDirectoryEvent(input: {
const info = (event.properties as { info: Session }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id)
if (info.time.archived) {
if (input.store.session[result.index]!.time.archived === info.time.archived) break
if (result.found) {
input.setStore(
"session",
-1
View File
@@ -276,7 +276,6 @@ export const dict = {
"mcp.status.connected": "متصل",
"mcp.status.failed": "فشل",
"mcp.status.needs_auth": "يحتاج إلى مصادقة",
"mcp.auth.clickToAuthenticate": "انقر للمصادقة",
"mcp.status.disabled": "معطل",
"dialog.fork.empty": "لا توجد رسائل للتفرع منها",
"dialog.directory.search.placeholder": "البحث في المجلدات",
-1
View File
@@ -276,7 +276,6 @@ export const dict = {
"mcp.status.connected": "conectado",
"mcp.status.failed": "falhou",
"mcp.status.needs_auth": "precisa de autenticação",
"mcp.auth.clickToAuthenticate": "Clique para autenticar",
"mcp.status.disabled": "desabilitado",
"dialog.fork.empty": "Nenhuma mensagem para bifurcar",
"dialog.directory.search.placeholder": "Buscar pastas",
-1
View File
@@ -300,7 +300,6 @@ export const dict = {
"mcp.status.connected": "povezano",
"mcp.status.failed": "neuspjelo",
"mcp.status.needs_auth": "potrebna autentifikacija",
"mcp.auth.clickToAuthenticate": "Kliknite za autentifikaciju",
"mcp.status.disabled": "onemogućeno",
"dialog.fork.empty": "Nema poruka za fork",
-1
View File
@@ -298,7 +298,6 @@ export const dict = {
"mcp.status.connected": "forbundet",
"mcp.status.failed": "mislykkedes",
"mcp.status.needs_auth": "kræver godkendelse",
"mcp.auth.clickToAuthenticate": "Klik for at godkende",
"mcp.status.disabled": "deaktiveret",
"dialog.fork.empty": "Ingen beskeder at forgrene fra",
-1
View File
@@ -282,7 +282,6 @@ export const dict = {
"mcp.status.connected": "verbunden",
"mcp.status.failed": "fehlgeschlagen",
"mcp.status.needs_auth": "benötigt Authentifizierung",
"mcp.auth.clickToAuthenticate": "Zum Authentifizieren klicken",
"mcp.status.disabled": "deaktiviert",
"dialog.fork.empty": "Keine Nachrichten zum Abzweigen vorhanden",
"dialog.directory.search.placeholder": "Ordner durchsuchen",
+1 -2
View File
@@ -306,7 +306,6 @@ export const dict = {
"mcp.status.failed": "failed",
"mcp.status.needs_auth": "needs auth",
"mcp.status.disabled": "disabled",
"mcp.auth.clickToAuthenticate": "Click to authenticate",
"dialog.fork.empty": "No messages to fork from",
@@ -903,7 +902,7 @@ export const dict = {
"settings.permissions.tool.read.title": "Read",
"settings.permissions.tool.read.description": "Reading a file (matches the file path)",
"settings.permissions.tool.edit.title": "Edit",
"settings.permissions.tool.edit.description": "Modify files, including edits, writes, and patches",
"settings.permissions.tool.edit.description": "Modify files, including edits, writes, patches, and multi-edits",
"settings.permissions.tool.glob.title": "Glob",
"settings.permissions.tool.glob.description": "Match files using glob patterns",
"settings.permissions.tool.grep.title": "Grep",
-1
View File
@@ -299,7 +299,6 @@ export const dict = {
"mcp.status.connected": "conectado",
"mcp.status.failed": "fallido",
"mcp.status.needs_auth": "necesita auth",
"mcp.auth.clickToAuthenticate": "Haz clic para autenticar",
"mcp.status.disabled": "deshabilitado",
"dialog.fork.empty": "No hay mensajes desde donde bifurcar",
-1
View File
@@ -277,7 +277,6 @@ export const dict = {
"mcp.status.connected": "connecté",
"mcp.status.failed": "échoué",
"mcp.status.needs_auth": "nécessite auth",
"mcp.auth.clickToAuthenticate": "Cliquez pour vous authentifier",
"mcp.status.disabled": "désactivé",
"dialog.fork.empty": "Aucun message à partir duquel bifurquer",
"dialog.directory.search.placeholder": "Rechercher des dossiers",
-1
View File
@@ -275,7 +275,6 @@ export const dict = {
"mcp.status.connected": "接続済み",
"mcp.status.failed": "失敗",
"mcp.status.needs_auth": "認証が必要",
"mcp.auth.clickToAuthenticate": "クリックして認証",
"mcp.status.disabled": "無効",
"dialog.fork.empty": "フォーク元のメッセージがありません",
"dialog.directory.search.placeholder": "フォルダを検索",
-1
View File
@@ -275,7 +275,6 @@ export const dict = {
"mcp.status.connected": "연결됨",
"mcp.status.failed": "실패",
"mcp.status.needs_auth": "인증 필요",
"mcp.auth.clickToAuthenticate": "클릭하여 인증",
"mcp.status.disabled": "비활성화됨",
"dialog.fork.empty": "분기할 메시지 없음",
"dialog.directory.search.placeholder": "폴더 검색",
-1
View File
@@ -302,7 +302,6 @@ export const dict = {
"mcp.status.connected": "tilkoblet",
"mcp.status.failed": "mislyktes",
"mcp.status.needs_auth": "trenger autentisering",
"mcp.auth.clickToAuthenticate": "Klikk for å autentisere",
"mcp.status.disabled": "deaktivert",
"dialog.fork.empty": "Ingen meldinger å forgrene fra",
-1
View File
@@ -277,7 +277,6 @@ export const dict = {
"mcp.status.connected": "połączono",
"mcp.status.failed": "niepowodzenie",
"mcp.status.needs_auth": "wymaga autoryzacji",
"mcp.auth.clickToAuthenticate": "Kliknij, aby się uwierzytelnić",
"mcp.status.disabled": "wyłączone",
"dialog.fork.empty": "Brak wiadomości do rozwidlenia",
"dialog.directory.search.placeholder": "Szukaj folderów",
-1
View File
@@ -299,7 +299,6 @@ export const dict = {
"mcp.status.connected": "подключено",
"mcp.status.failed": "ошибка",
"mcp.status.needs_auth": "требуется авторизация",
"mcp.auth.clickToAuthenticate": "Нажмите, чтобы авторизоваться",
"mcp.status.disabled": "отключено",
"dialog.fork.empty": "Нет сообщений для ответвления",
-1
View File
@@ -299,7 +299,6 @@ export const dict = {
"mcp.status.connected": "เชื่อมต่อแล้ว",
"mcp.status.failed": "ล้มเหลว",
"mcp.status.needs_auth": "ต้องการการตรวจสอบสิทธิ์",
"mcp.auth.clickToAuthenticate": "คลิกเพื่อยืนยันตัวตน",
"mcp.status.disabled": "ปิดใช้งาน",
"dialog.fork.empty": "ไม่มีข้อความให้แตกแขนง",
-1
View File
@@ -304,7 +304,6 @@ export const dict = {
"mcp.status.connected": "bağlı",
"mcp.status.failed": "başarısız",
"mcp.status.needs_auth": "kimlik doğrulama gerekli",
"mcp.auth.clickToAuthenticate": "Kimlik doğrulamak için tıklayın",
"mcp.status.disabled": "devre dışı",
"dialog.fork.empty": "Dallandırılacak mesaj yok",
-1
View File
@@ -319,7 +319,6 @@ export const dict = {
"mcp.status.connected": "已连接",
"mcp.status.failed": "失败",
"mcp.status.needs_auth": "需要授权",
"mcp.auth.clickToAuthenticate": "点击进行授权",
"mcp.status.disabled": "已禁用",
"dialog.fork.empty": "没有可用于分叉的消息",
-1
View File
@@ -299,7 +299,6 @@ export const dict = {
"mcp.status.connected": "已連線",
"mcp.status.failed": "失敗",
"mcp.status.needs_auth": "需要授權",
"mcp.auth.clickToAuthenticate": "點擊以進行授權",
"mcp.status.disabled": "已停用",
"dialog.fork.empty": "沒有可用於分支的訊息",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.15.0",
"version": "1.14.49",
"type": "module",
"license": "MIT",
"scripts": {
+5 -5
View File
@@ -9,8 +9,8 @@ export const config = {
github: {
repoUrl: "https://github.com/anomalyco/opencode",
starsFormatted: {
compact: "160K",
full: "160,000",
compact: "150K",
full: "150,000",
},
},
@@ -22,8 +22,8 @@ export const config = {
// Static stats (used on landing page)
stats: {
contributors: "900",
commits: "13,000",
monthlyUsers: "7.5M",
contributors: "850",
commits: "11,000",
monthlyUsers: "6.5M",
},
} as const
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.15.0",
"version": "1.14.49",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.15.0",
"version": "1.14.49",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.15.0",
"version": "1.14.49",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.0",
"version": "1.14.49",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+8 -17
View File
@@ -5,8 +5,7 @@ import { produce, type Draft } from "immer"
import { ModelV2 } from "./model"
import { PluginV2 } from "./plugin"
import { ProviderV2 } from "./provider"
import { Location } from "./location"
import { EventV2 } from "./event"
import { Instance } from "./instance"
type ProviderRecord = {
provider: ProviderV2.Info
@@ -25,15 +24,6 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundErr
modelID: ModelV2.ID,
}) {}
export const Event = {
ModelUpdated: EventV2.define({
type: "catalog.model.updated",
schema: {
model: ModelV2.Info,
},
}),
}
export interface Interface {
readonly provider: {
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info, ProviderNotFoundError>
@@ -67,11 +57,10 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
yield* Location.Service
yield* Instance.Service
let records = HashMap.empty<ProviderV2.ID, ProviderRecord>()
let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined
const plugin = yield* PluginV2.Service
const events = yield* EventV2.Service
const resolve = (model: ModelV2.Info) => {
const provider = Option.getOrThrow(HashMap.get(records, model.providerID)).provider
@@ -168,12 +157,14 @@ export const layer = Layer.effect(
)
const updated = yield* plugin.trigger("model.update", {}, { model, cancel: false })
if (updated.cancel) return
const next = new ModelV2.Info({ ...updated.model, id: modelID, providerID })
records = HashMap.set(records, providerID, {
provider: record.provider,
models: HashMap.set(record.models, modelID, next),
models: HashMap.set(
record.models,
modelID,
new ModelV2.Info({ ...updated.model, id: modelID, providerID }),
),
})
yield* events.publish(Event.ModelUpdated, { model: resolve(next) })
return
}),
@@ -266,4 +257,4 @@ export const layer = Layer.effect(
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
export const defaultLayer = layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provide(PluginV2.defaultLayer))
-157
View File
@@ -1,157 +0,0 @@
import { Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Location } from "./location"
import { withStatics } from "./schema"
import { Identifier } from "./util/identifier"
export const ID = Schema.String.pipe(
Schema.brand("Event.ID"),
withStatics((schema) => ({ create: () => schema.make("evt_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
export type Definition<Type extends string = string, DataSchema extends Schema.Top = Schema.Top> = {
readonly type: Type
readonly version?: number
readonly aggregate?: string
readonly data: DataSchema
}
export type Data<D extends Definition> = Schema.Schema.Type<D["data"]>
export type Payload<D extends Definition = Definition> = {
readonly id: ID
readonly type: D["type"]
readonly data: Data<D>
readonly version?: number
readonly location?: Location.Ref
readonly metadata?: Record<string, unknown>
}
export type Sync = (event: Payload) => Effect.Effect<void>
export const registry = new Map<string, Definition>()
export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
readonly type: Type
readonly version?: number
readonly aggregate?: string
readonly schema: Fields
}): Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> & Definition<Type, Schema.Struct<Fields>> {
const Data = Schema.Struct(input.schema)
const Payload = Schema.Struct({
id: ID,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
type: Schema.Literal(input.type),
version: Schema.optional(Schema.Number),
location: Schema.optional(Location.Ref),
data: Data,
}).annotate({ identifier: input.type })
const definition = Object.assign(Payload, {
type: input.type,
...(input.version === undefined ? {} : { version: input.version }),
...(input.aggregate === undefined ? {} : { aggregate: input.aggregate }),
data: Data,
})
registry.set(input.type, definition)
return definition as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> &
Definition<Type, Schema.Struct<Fields>>
}
export function definitions() {
return registry.values().toArray()
}
export interface PublishOptions {
readonly id?: ID
readonly metadata?: Record<string, unknown>
}
export type Unsubscribe = Effect.Effect<void>
export interface Interface {
readonly publish: <D extends Definition>(
definition: D,
data: Data<D>,
options?: PublishOptions,
) => Effect.Effect<Payload<D>>
readonly publishEvent: <D extends Definition>(event: Payload<D>) => Effect.Effect<Payload<D>>
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
readonly all: () => Stream.Stream<Payload>
readonly sync: (handler: Sync) => Effect.Effect<Unsubscribe>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const all = yield* PubSub.unbounded<Payload>()
const typed = new Map<string, PubSub.PubSub<Payload>>()
const syncHandlers = new Array<Sync>()
const getOrCreate = (definition: Definition) =>
Effect.gen(function* () {
const existing = typed.get(definition.type)
if (existing) return existing
const pubsub = yield* PubSub.unbounded<Payload>()
typed.set(definition.type, pubsub)
return pubsub
})
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
yield* PubSub.shutdown(all)
yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true })
}),
)
function publishEvent<D extends Definition>(event: Payload<D>) {
return Effect.gen(function* () {
for (const sync of syncHandlers) {
yield* sync(event as Payload)
}
const pubsub = typed.get(event.type)
if (pubsub) yield* PubSub.publish(pubsub, event as Payload)
yield* PubSub.publish(all, event as Payload)
return event
})
}
function publish<D extends Definition>(definition: D, data: Data<D>, options?: PublishOptions) {
return Effect.gen(function* () {
const location = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
const event = {
id: options?.id ?? ID.create(),
...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type,
...(definition.version === undefined ? {} : { version: definition.version }),
...(location ? { location } : {}),
data,
} as Payload<D>
return yield* publishEvent(event)
})
}
const subscribe = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> =>
Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe(
Stream.map((event) => event as Payload<D>),
)
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(all)
const sync = (handler: Sync): Effect.Effect<Unsubscribe> =>
Effect.sync(() => {
syncHandlers.push(handler)
return Effect.sync(() => {
const index = syncHandlers.indexOf(handler)
if (index >= 0) syncHandlers.splice(index, 1)
})
})
return Service.of({ publish, publishEvent, subscribe, all: streamAll, sync })
}),
)
export const defaultLayer = layer
export * as EventV2 from "./event"
+42 -1
View File
@@ -5,13 +5,29 @@ function truthy(key: string) {
return value === "true" || value === "1"
}
function falsy(key: string) {
const value = process.env[key]?.toLowerCase()
return value === "false" || value === "0"
}
function number(key: string) {
const value = process.env[key]
if (!value) return undefined
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
}
const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
const OPENCODE_DISABLE_CLAUDE_CODE = truthy("OPENCODE_DISABLE_CLAUDE_CODE")
const OPENCODE_DISABLE_CLAUDE_CODE_SKILLS =
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")
const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
export const Flag = {
OTEL_EXPORTER_OTLP_ENDPOINT: process.env["OTEL_EXPORTER_OTLP_ENDPOINT"],
OTEL_EXPORTER_OTLP_HEADERS: process.env["OTEL_EXPORTER_OTLP_HEADERS"],
OPENCODE_AUTO_SHARE: truthy("OPENCODE_AUTO_SHARE"),
OPENCODE_AUTO_HEAP_SNAPSHOT: truthy("OPENCODE_AUTO_HEAP_SNAPSHOT"),
OPENCODE_GIT_BASH_PATH: process.env["OPENCODE_GIT_BASH_PATH"],
OPENCODE_CONFIG: process.env["OPENCODE_CONFIG"],
@@ -22,29 +38,54 @@ 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_ENABLE_EXPERIMENTAL_MODELS: truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS"),
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_CLAUDE_CODE_SKILLS,
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),
),
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER").pipe(
Config.withDefault(false),
),
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT:
copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"),
OPENCODE_EXPERIMENTAL_MINIMAL_THINKING: truthy("OPENCODE_EXPERIMENTAL_MINIMAL_THINKING"),
OPENCODE_ENABLE_EXA: truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA"),
OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: number("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: number("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
OPENCODE_EXPERIMENTAL_OXFMT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT"),
OPENCODE_EXPERIMENTAL_LSP_TY: truthy("OPENCODE_EXPERIMENTAL_LSP_TY"),
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_EXPERIMENTAL_MARKDOWN: !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN"),
OPENCODE_ENABLE_PARALLEL: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"),
OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"],
OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"],
OPENCODE_DISABLE_EMBEDDED_WEB_UI: truthy("OPENCODE_DISABLE_EMBEDDED_WEB_UI"),
OPENCODE_DB: process.env["OPENCODE_DB"],
OPENCODE_DISABLE_CHANNEL_DB: truthy("OPENCODE_DISABLE_CHANNEL_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.
+12
View File
@@ -0,0 +1,12 @@
import { Layer, LayerMap } from "effect"
import { Instance } from "./instance"
import { Catalog } from "./catalog"
import { PluginBoot } from "./plugin/boot"
export class InstanceServiceMap extends LayerMap.Service<InstanceServiceMap>()("@opencode/example/InstanceServiceMap", {
lookup: (ref: Instance.Ref) => {
const instance = Layer.succeed(Instance.Service, Instance.Service.of(ref))
return Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(Layer.provide(instance))
},
idleTimeToLive: "5 minutes",
}) {}
+10
View File
@@ -0,0 +1,10 @@
import { Context } from "effect"
export * as Instance from "./instance"
export type Ref = {
readonly directory: string
readonly workspaceID?: string
}
export class Service extends Context.Service<Service, Ref>()("@opencode/Instance") {}
-12
View File
@@ -1,12 +0,0 @@
import { Layer, LayerMap } from "effect"
import { Location } from "./location"
import { Catalog } from "./catalog"
import { PluginBoot } from "./plugin/boot"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) => {
const location = Layer.succeed(Location.Service, Location.Service.of(ref))
return Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(Layer.provide(location))
},
idleTimeToLive: "5 minutes",
}) {}
-11
View File
@@ -1,11 +0,0 @@
import { Context, Schema } from "effect"
export * as Location from "./location"
export const Ref = Schema.Struct({
directory: Schema.String,
workspaceID: Schema.optional(Schema.String),
}).annotate({ identifier: "Location.Ref" })
export type Ref = typeof Ref.Type
export class Service extends Context.Service<Service, Ref>()("@opencode/Location") {}
@@ -10,7 +10,6 @@ export const NvidiaPlugin = PluginV2.define({
if (evt.provider.id !== ProviderV2.ID.make("nvidia")) return
evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
evt.provider.options.headers["X-Title"] = "opencode"
evt.provider.options.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode"
}),
}
}),
-13
View File
@@ -1,13 +0,0 @@
export * as Session from "./session"
import { Schema } from "effect"
import { withStatics } from "./schema"
import { Identifier } from "./util/identifier"
export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
Schema.brand("SessionID"),
withStatics((schema) => ({
descending: (id?: string) => schema.make(id ?? "ses_" + Identifier.descending()),
})),
)
export type ID = typeof ID.Type
+4 -36
View File
@@ -1,21 +1,14 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Fiber, Layer, Option, Stream } from "effect"
import { DateTime, Effect, Layer, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { Instance } from "@opencode-ai/core/instance"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "./lib/effect"
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
const it = testEffect(
Catalog.layer.pipe(
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(locationLayer),
),
)
const instanceLayer = Layer.succeed(Instance.Service, Instance.Service.of({ directory: "test" }))
const it = testEffect(Catalog.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer), Layer.provide(instanceLayer)))
describe("CatalogV2", () => {
it.effect("normalizes provider baseURL into endpoint url", () =>
@@ -76,31 +69,6 @@ describe("CatalogV2", () => {
}),
)
it.effect("publishes model updated events", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const events = yield* EventV2.Service
const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model")
const fiber = yield* events
.subscribe(Catalog.Event.ModelUpdated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* catalog.provider.update(providerID, () => {})
yield* catalog.model.update(providerID, modelID, (model) => {
model.name = "Updated Model"
})
const event = Array.from(yield* Fiber.join(fiber))[0]
expect(event?.type).toBe("catalog.model.updated")
expect(event?.data.model.providerID).toBe(providerID)
expect(event?.data.model.id).toBe(modelID)
expect(event?.data.model.name).toBe("Updated Model")
expect(event?.location).toEqual({ directory: "test" })
}),
)
it.effect("resolves unknown model endpoint from provider endpoint", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
-132
View File
@@ -1,132 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { testEffect } from "./lib/effect"
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of({ directory: "project", workspaceID: "workspace" }),
)
const it = testEffect(EventV2.layer.pipe(Layer.provideMerge(locationLayer)))
const itWithoutLocation = testEffect(EventV2.layer)
const Message = EventV2.define({
type: "test.message",
schema: {
text: Schema.String,
},
})
const GlobalMessage = EventV2.define({
type: "test.global",
schema: {
text: Schema.String,
},
})
const VersionedMessage = EventV2.define({
type: "test.versioned",
version: 2,
schema: {
text: Schema.String,
},
})
describe("EventV2", () => {
it.effect("publishes events with the current location", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const fiber = yield* events.subscribe(Message).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const event = yield* events.publish(Message, { text: "hello" })
const received = Array.from(yield* Fiber.join(fiber))
expect(received).toEqual([event])
expect(event.type).toBe("test.message")
expect(event).not.toHaveProperty("version")
expect(event.data).toEqual({ text: "hello" })
expect(event.location).toEqual({ directory: "project", workspaceID: "workspace" })
}),
)
itWithoutLocation.effect("omits location when no location is available", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const event = yield* events.publish(GlobalMessage, { text: "hello" })
expect(event).not.toHaveProperty("location")
expect(event.type).toBe("test.global")
}),
)
it.effect("publishes definition version", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const event = yield* events.publish(VersionedMessage, { text: "hello" })
expect(event.type).toBe("test.versioned")
expect(event.version).toBe(2)
}),
)
it.effect("stores definitions in the exported registry", () =>
Effect.sync(() => {
expect(EventV2.registry.get(Message.type)).toBe(Message)
}),
)
it.effect("publishes to typed and wildcard subscriptions", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const typed = yield* events.subscribe(Message).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
const wildcard = yield* events.all().pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const event = yield* events.publish(Message, { text: "hello" })
expect(Array.from(yield* Fiber.join(typed))).toEqual([event])
expect(Array.from(yield* Fiber.join(wildcard))).toEqual([event])
}),
)
it.effect("runs sync handlers inline", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const received = new Array<EventV2.Payload>()
const unsubscribe = yield* events.sync((event) =>
Effect.sync(() => {
received.push(event)
}),
)
const event = yield* events.publish(Message, { text: "hello" })
yield* unsubscribe
yield* events.publish(Message, { text: "after unsubscribe" })
expect(received).toEqual([event])
}),
)
it.effect("runs sync handlers before publishing to streams", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const received = new Array<string>()
const fiber = yield* events.all().pipe(
Stream.take(1),
Stream.runForEach(() => Effect.sync(() => received.push("stream"))),
Effect.forkScoped,
)
yield* events.sync((event) =>
Effect.sync(() => {
received.push(event.type)
}),
)
yield* Effect.yieldNow
yield* events.publish(Message, { text: "hello" })
yield* Fiber.join(fiber)
expect(received).toEqual([Message.type, "stream"])
}),
)
})
@@ -15,7 +15,7 @@ describe("NvidiaPlugin", () => {
),
)
it.effect("applies NVIDIA tracking headers only to nvidia", () =>
it.effect("applies legacy referer headers only to nvidia", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(NvidiaPlugin)
@@ -34,60 +34,8 @@ describe("NvidiaPlugin", () => {
Existing: "value",
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
})
expect(ignored.provider.options.headers).toEqual({})
}),
)
it.effect("adds billing origin for custom NVIDIA endpoints", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(NvidiaPlugin)
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("nvidia", {
endpoint: { type: "aisdk", package: "test-provider", url: "http://localhost:8000/v1" },
options: { headers: {}, body: {}, aisdk: { provider: {}, request: {} } },
}),
cancel: false,
},
)
expect(result.provider.options.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
})
}),
)
it.effect("preserves an explicit NVIDIA billing origin header", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(NvidiaPlugin)
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("nvidia", {
options: {
headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" },
body: {},
aisdk: { provider: { baseURL: "https://integrate.api.nvidia.com/v1" }, request: {} },
},
}),
cancel: false,
},
)
expect(result.provider.options.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
"X-BILLING-INVOKE-ORIGIN": "CustomOrigin",
})
}),
)
})
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Layer, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Location } from "@opencode-ai/core/location"
import { Instance } from "@opencode-ai/core/instance"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
@@ -9,7 +9,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { it, model, provider, withEnv } from "./provider-helper"
const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
const instanceLayer = Layer.succeed(Instance.Service, Instance.Service.of({ directory: "test" }))
describe("OpencodePlugin", () => {
it.effect("uses a public key and cancels paid models without credentials", () =>
@@ -192,6 +192,6 @@ describe("OpencodePlugin", () => {
const selected = yield* catalog.model.small(providerID)
expect(Option.getOrUndefined(selected)?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
}).pipe(Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(locationLayer)))),
}).pipe(Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(instanceLayer)))),
)
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.15.0",
"version": "1.14.49",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -58,7 +58,7 @@ async function checkMacosApp(appName: string) {
async function resolveWindowsAppPath(appName: string): Promise<string | null> {
let output: string
try {
output = await execFilePromise("where", [appName]).then((r) => r.stdout.toString())
output = execFilePromise("where", [appName]).toString()
} catch {
return null
}
+1 -1
View File
@@ -19,7 +19,7 @@ declare module "virtual:opencode-server" {
export const init: typeof import("../../../opencode/dist/types/src/node").Log.init
}
export namespace Database {
export const getPath: typeof import("../../../opencode/dist/types/src/node").Database.getPath
export const Path: typeof import("../../../opencode/dist/types/src/node").Database.Path
export const Client: typeof import("../../../opencode/dist/types/src/node").Database.Client
}
export namespace JsonMigration {
-2
View File
@@ -84,7 +84,6 @@ export function createMainWindow() {
width: state.width,
height: state.height,
show: false,
autoHideMenuBar: true,
title: "OpenCode",
icon: iconPath(),
backgroundColor,
@@ -143,7 +142,6 @@ export function createLoadingWindow() {
resizable: false,
center: true,
show: true,
autoHideMenuBar: true,
icon: iconPath(),
backgroundColor,
...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.15.0",
"version": "1.14.49",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.15.0"
version = "1.14.49"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.49/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.49/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.49/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.49/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.49/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.15.0",
"version": "1.14.49",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+15 -14
View File
@@ -166,11 +166,11 @@ import { Effect } from "effect"
const audit = Effect.gen(function* () {
const cassettes = yield* HttpRecorder.Cassette.Service
const names = yield* cassettes.list()
const issues = yield* Effect.forEach(names, (name) =>
const entries = yield* cassettes.list()
const issues = yield* Effect.forEach(entries, (entry) =>
cassettes
.read(name)
.pipe(Effect.map((interactions) => ({ name, findings: HttpRecorder.secretFindings(interactions) }))),
.read(entry.name)
.pipe(Effect.map((interactions) => ({ name: entry.name, findings: HttpRecorder.secretFindings(interactions) }))),
)
return issues.filter((i) => i.findings.length > 0)
})
@@ -196,13 +196,14 @@ type RecordReplayOptions = {
## Layout
| File | Purpose |
| -------------- | --------------------------------------------------------------------------- |
| `effect.ts` | `cassetteLayer` / `recordingLayer` — the `HttpClient` adapter. |
| `websocket.ts` | `makeWebSocketExecutor` — WebSocket record/replay. |
| `cassette.ts` | `Cassette.Service``fileSystem` / `memory` adapters, error types. |
| `recorder.ts` | Shared transport plumbing: `resolveAutoMode`, `ReplayState`. |
| `redactor.ts` | Composable `Redactor` — headers, url, body redaction. |
| `redaction.ts` | Lower-level header/URL primitives + secret pattern detection. |
| `schema.ts` | Effect Schema definitions for the cassette JSON format. |
| `matching.ts` | Request matcher, canonicalization, sequential cursor, mismatch diagnostics. |
| File | Purpose |
| -------------- | -------------------------------------------------------------------------------- |
| `effect.ts` | `cassetteLayer` / `recordingLayer` — the `HttpClient` adapter. |
| `websocket.ts` | `makeWebSocketExecutor` — WebSocket record/replay. |
| `cassette.ts` | `Cassette.Service`reads/writes cassette files, accumulates state. |
| `recorder.ts` | Shared transport plumbing: `UnsafeCassetteError`, `appendOrFail`, `ReplayState`. |
| `redactor.ts` | Composable `Redactor` — headers, url, body redaction. |
| `redaction.ts` | Lower-level header/URL primitives + secret pattern detection. |
| `schema.ts` | Effect Schema definitions for the cassette JSON format. |
| `storage.ts` | Path resolution, JSON encode/decode, sync existence check. |
| `matching.ts` | Request matcher, canonicalization, sequential cursor, mismatch diagnostics. |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.0",
"version": "1.14.49",
"name": "@opencode-ai/http-recorder",
"type": "module",
"license": "MIT",
+20 -32
View File
@@ -1,7 +1,7 @@
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
import * as fs from "node:fs"
import * as path from "node:path"
import { secretFindings, SecretFindingSchema, type SecretFinding } from "./redaction"
import { secretFindings, type SecretFinding } from "./redaction"
import { decodeCassette, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema"
const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
@@ -14,24 +14,13 @@ export class CassetteNotFoundError extends Schema.TaggedErrorClass<CassetteNotFo
}
}
export class UnsafeCassetteError extends Schema.TaggedErrorClass<UnsafeCassetteError>()("UnsafeCassetteError", {
cassetteName: Schema.String,
findings: Schema.Array(SecretFindingSchema),
}) {
override get message() {
return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings
.map((finding) => `${finding.path} (${finding.reason})`)
.join(", ")}`
}
export interface AppendResult {
readonly findings: ReadonlyArray<SecretFinding>
}
export interface Interface {
readonly read: (name: string) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError>
readonly append: (
name: string,
interaction: Interaction,
metadata?: CassetteMetadata,
) => Effect.Effect<void, UnsafeCassetteError>
readonly append: (name: string, interaction: Interaction, metadata?: CassetteMetadata) => Effect.Effect<AppendResult>
readonly exists: (name: string) => Effect.Effect<boolean>
readonly list: () => Effect.Effect<ReadonlyArray<string>>
}
@@ -55,9 +44,6 @@ const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(
const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw))
const failIfUnsafe = (name: string, findings: ReadonlyArray<SecretFinding>) =>
findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings }))
export const fileSystem = (
options: { readonly directory?: string } = {},
): Layer.Layer<Service, never, FileSystem.FileSystem> =>
@@ -106,9 +92,11 @@ export const fileSystem = (
entry.findings.push(...secretFindings(interaction))
const cassette = buildCassette(name, entry.interactions, metadata)
const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})]
yield* failIfUnsafe(name, findings)
yield* ensureDirectory(name)
yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie)
if (findings.length === 0) {
yield* ensureDirectory(name)
yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie)
}
return { findings }
}),
exists: (name) =>
fs.access(cassettePath(name)).pipe(
@@ -145,17 +133,17 @@ export const memory = (initial: Record<string, ReadonlyArray<Interaction>> = {})
stored.has(name)
? Effect.succeed(stored.get(name) ?? [])
: Effect.fail(new CassetteNotFoundError({ cassetteName: name })),
append: (name, interaction, metadata) => {
const existing = stored.get(name)
if (existing) existing.push(interaction)
else stored.set(name, [interaction])
const existingFindings = accumulatedFindings.get(name)
const findings = existingFindings ?? []
if (!existingFindings) accumulatedFindings.set(name, findings)
findings.push(...secretFindings(interaction))
if (metadata) findings.push(...secretFindings({ name, ...metadata }))
return failIfUnsafe(name, findings)
},
append: (name, interaction, metadata) =>
Effect.sync(() => {
const existing = stored.get(name)
if (existing) existing.push(interaction)
else stored.set(name, [interaction])
const findings = accumulatedFindings.get(name)
if (findings) findings.push(...secretFindings(interaction))
else accumulatedFindings.set(name, [...secretFindings(interaction)])
if (metadata) accumulatedFindings.get(name)!.push(...secretFindings({ name, ...metadata }))
return { findings: accumulatedFindings.get(name) ?? [] }
}),
exists: (name) => Effect.sync(() => stored.has(name)),
list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()),
})
+4 -6
View File
@@ -12,7 +12,7 @@ import {
} from "effect/unstable/http"
import * as CassetteService from "./cassette"
import { defaultMatcher, selectSequential, type RequestMatcher } from "./matching"
import { makeReplayState, resolveAutoMode } from "./recorder"
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder"
import { defaults, type Redactor } from "./redactor"
import { redactUrl } from "./redaction"
import { httpInteractions, type CassetteMetadata, type HttpInteraction, type ResponseSnapshot } from "./schema"
@@ -100,11 +100,9 @@ export const recordingLayer = (
...captured,
}),
}
yield* cassetteService
.append(name, interaction, options.metadata)
.pipe(
Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(transportError(request, error.message))),
)
yield* appendOrFail(cassetteService, name, interaction, options.metadata).pipe(
Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(transportError(request, error.message))),
)
return HttpClientResponse.fromWeb(
request,
new Response(decodeResponseBody(interaction.response), interaction.response),
+2 -1
View File
@@ -7,9 +7,10 @@ export type {
WebSocketFrame,
WebSocketInteraction,
} from "./schema"
export { CassetteNotFoundError, hasCassetteSync, UnsafeCassetteError } from "./cassette"
export { CassetteNotFoundError, hasCassetteSync } from "./cassette"
export { defaultMatcher, type RequestMatcher } from "./matching"
export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction"
export { UnsafeCassetteError } from "./recorder"
export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./effect"
export {
makeWebSocketExecutor,
+1 -1
View File
@@ -36,7 +36,7 @@ export const canonicalSnapshot = (snapshot: RequestSnapshot): string =>
export const defaultMatcher: RequestMatcher = (incoming, recorded) =>
canonicalSnapshot(incoming) === canonicalSnapshot(recorded)
export const safeText = (value: unknown) => {
const safeText = (value: unknown) => {
if (value === undefined) return "undefined"
if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
const text = JSON.stringify(value)
+31 -6
View File
@@ -1,22 +1,47 @@
import { Effect, Ref, Scope } from "effect"
import { Effect, Ref, Schema, Scope } from "effect"
import type * as CassetteService from "./cassette"
import type { CassetteNotFoundError } from "./cassette"
import type { Interaction } from "./schema"
import { SecretFindingSchema } from "./redaction"
import type { CassetteMetadata, Interaction } from "./schema"
export class UnsafeCassetteError extends Schema.TaggedErrorClass<UnsafeCassetteError>()("UnsafeCassetteError", {
cassetteName: Schema.String,
findings: Schema.Array(SecretFindingSchema),
}) {
override get message() {
return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings
.map((finding) => `${finding.path} (${finding.reason})`)
.join(", ")}`
}
}
export type ResolvedMode = "record" | "replay" | "passthrough"
const isCI = () => {
const value = process.env.CI
return value !== undefined && value !== "" && value !== "false" && value !== "0"
}
export const resolveAutoMode = (
cassette: CassetteService.Interface,
name: string,
): Effect.Effect<"record" | "replay" | "passthrough"> =>
export const resolveAutoMode = (cassette: CassetteService.Interface, name: string): Effect.Effect<ResolvedMode> =>
Effect.gen(function* () {
if (isCI()) return "replay"
return (yield* cassette.exists(name)) ? "replay" : "record"
})
export const appendOrFail = (
cassette: CassetteService.Interface,
name: string,
interaction: Interaction,
metadata: CassetteMetadata | undefined,
): Effect.Effect<void, UnsafeCassetteError> =>
cassette
.append(name, interaction, metadata)
.pipe(
Effect.flatMap(({ findings }) =>
findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings })),
),
)
export interface ReplayState<T> {
readonly load: Effect.Effect<ReadonlyArray<T>, CassetteNotFoundError>
readonly cursor: Effect.Effect<number>
+2 -2
View File
@@ -1,5 +1,3 @@
import { Schema } from "effect"
export const REDACTED = "[REDACTED]"
const DEFAULT_REDACT_HEADERS = [
@@ -97,6 +95,8 @@ export const redactHeaders = (
)
}
import { Schema } from "effect"
export const SecretFindingSchema = Schema.Struct({
path: Schema.String,
reason: Schema.String,
+16 -17
View File
@@ -1,10 +1,9 @@
import { Effect, Option, Ref, Scope, Stream } from "effect"
import type { Headers } from "effect/unstable/http"
import * as CassetteService from "./cassette"
import { canonicalizeJson, decodeJson, safeText } from "./matching"
import { makeReplayState, resolveAutoMode } from "./recorder"
import { canonicalizeJson, decodeJson } from "./matching"
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder"
import type { RecordReplayMode } from "./effect"
import { redactUrl } from "./redaction"
import { defaults, type Redactor } from "./redactor"
import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame } from "./schema"
@@ -54,7 +53,7 @@ const decodeFrameText = (frame: WebSocketFrame) =>
const assertEqual = (message: string, actual: unknown, expected: unknown) =>
Effect.sync(() => {
if (JSON.stringify(actual) === JSON.stringify(expected)) return
throw new Error(`${message}: expected ${safeText(expected)}, received ${safeText(actual)}`)
throw new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`)
})
const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson })
@@ -62,7 +61,7 @@ const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone:
const compareClientMessage = (actual: string, expected: WebSocketFrame | undefined, index: number, asJson: boolean) => {
if (!expected)
return Effect.sync(() => {
throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${safeText(actual)}`)
throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${actual}`)
})
const expectedText = decodeFrameText(expected)
if (!asJson) return assertEqual(`WebSocket client frame ${index + 1}`, actual, expectedText)
@@ -99,13 +98,12 @@ export const makeWebSocketExecutor = <E>(
const closeOnce = Effect.gen(function* () {
if (yield* Ref.getAndSet(closed, true)) return
yield* connection.close
yield* options.cassette
.append(
options.name,
{ transport: "websocket", open: openSnapshot(request), client, server },
options.metadata,
)
.pipe(Effect.orDie)
yield* appendOrFail(
options.cassette,
options.name,
{ transport: "websocket", open: openSnapshot(request), client, server },
options.metadata,
).pipe(Effect.orDie)
})
return {
sendText: (message) =>
@@ -113,7 +111,10 @@ export const makeWebSocketExecutor = <E>(
.sendText(message)
.pipe(Effect.tap(() => Effect.sync(() => client.push(encodeFrame(message))))),
messages: connection.messages.pipe(
Stream.tap((message) => Effect.sync(() => server.push(encodeFrame(message)))),
Stream.map((message) => {
server.push(encodeFrame(message))
return message
}),
),
close: closeOnce,
}
@@ -129,22 +130,20 @@ export const makeWebSocketExecutor = <E>(
const interactions = yield* replay.load.pipe(Effect.orDie)
const index = yield* replay.cursor
const interaction = interactions[index]
if (!interaction)
return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${redactUrl(request.url)}`))
if (!interaction) return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${request.url}`))
yield* replay.advance
yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request), interaction.open)
const messageIndex = yield* Ref.make(0)
return {
sendText: (message) =>
Effect.gen(function* () {
const current = yield* Ref.get(messageIndex)
const current = yield* Ref.getAndUpdate(messageIndex, (value) => value + 1)
yield* compareClientMessage(
message,
interaction.client[current],
current,
options.compareClientMessagesAsJson === true,
)
yield* Ref.update(messageIndex, (value) => value + 1)
}),
messages: Stream.fromIterable(interaction.server).pipe(Stream.map(decodeFrameMessage)),
close: Effect.gen(function* () {
@@ -323,118 +323,4 @@ describe("http-recorder", () => {
}),
)
})
test("auto mode records to disk when the cassette is missing", async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-record-"))
using server = Bun.serve({
port: 0,
fetch: () => new Response('{"reply":"recorded"}', { headers: { "content-type": "application/json" } }),
})
const url = `http://127.0.0.1:${server.port}/echo`
// CI=true forces replay; clear it so we exercise the local-dev auto-record path.
const previous = process.env.CI
delete process.env.CI
try {
const result = await runWith("auto-record", { directory, mode: "auto" }, post(url, { step: 1 }))
expect(result).toBe('{"reply":"recorded"}')
expect(fs.existsSync(path.join(directory, "auto-record.json"))).toBe(true)
} finally {
if (previous !== undefined) process.env.CI = previous
}
})
test("passthrough mode bypasses the recorder entirely", async () => {
using server = Bun.serve({ port: 0, fetch: () => new Response("from-upstream") })
const url = `http://127.0.0.1:${server.port}/path`
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-passthrough-"))
const result = await runWith("passthrough-noop", { directory, mode: "passthrough" }, post(url, {}))
expect(result).toBe("from-upstream")
expect(fs.existsSync(path.join(directory, "passthrough-noop.json"))).toBe(false)
})
test("UnsafeCassetteError fails the request when a recording would write a known secret", async () => {
using server = Bun.serve({ port: 0, fetch: () => new Response("Bearer abcdefghijklmnopqrstuvwxyz1234") })
const url = `http://127.0.0.1:${server.port}/leaky`
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-unsafe-"))
const exit = await Effect.runPromise(
Effect.exit(
post(url, { ok: true }).pipe(
Effect.provide(HttpRecorder.cassetteLayer("unsafe-record", { directory, mode: "record" })),
),
),
)
expect(Exit.isFailure(exit)).toBe(true)
expect(failureText(exit)).toContain("contains possible secrets")
expect(fs.existsSync(path.join(directory, "unsafe-record.json"))).toBe(false)
})
test("Cassette.list enumerates recorded cassette names", async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-list-"))
await seedCassetteDirectory(directory, "alpha/one", [
{
transport: "http",
request: { method: "GET", url: "https://x.test/a", headers: {}, body: "" },
response: { status: 200, headers: {}, body: "a" },
},
])
await seedCassetteDirectory(directory, "beta", [
{
transport: "http",
request: { method: "GET", url: "https://x.test/b", headers: {}, body: "" },
response: { status: 200, headers: {}, body: "b" },
},
])
const names = await Effect.runPromise(
Effect.gen(function* () {
const cassette = yield* HttpRecorder.Cassette.Service
return yield* cassette.list()
}).pipe(Effect.provide(HttpRecorder.Cassette.fileSystem({ directory })), Effect.provide(NodeFileSystem.layer)),
)
expect(names).toEqual(["alpha/one", "beta"])
})
test("WebSocket replay decodes binary frames recorded as base64", async () => {
const binaryServer = new Uint8Array([1, 2, 3, 4])
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const cassette = yield* HttpRecorder.Cassette.Service
const executor = yield* HttpRecorder.makeWebSocketExecutor({
name: "ws/binary",
cassette,
live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
})
const connection = yield* executor.open({
url: "wss://example.test/binary",
headers: Headers.fromInput({}),
})
const messages: Array<string | Uint8Array> = []
yield* connection.messages.pipe(Stream.runForEach((m) => Effect.sync(() => messages.push(m))))
yield* connection.close
expect(messages).toHaveLength(1)
expect(messages[0]).toBeInstanceOf(Uint8Array)
expect(Array.from(messages[0] as Uint8Array)).toEqual([1, 2, 3, 4])
}).pipe(
Effect.provide(
HttpRecorder.Cassette.memory({
"ws/binary": [
{
transport: "websocket",
open: { url: "wss://example.test/binary", headers: {} },
client: [],
server: [
{ kind: "binary", body: Buffer.from(binaryServer).toString("base64"), bodyEncoding: "base64" },
],
},
],
}),
),
),
),
)
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.0",
"version": "1.14.49",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
+12 -3
View File
@@ -128,8 +128,17 @@ 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.
## Callback boundaries
## Instance.bind — ALS for native callbacks
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.
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called.
Plain async code should pass explicit context or stay inside an Effect fiber; do not add ambient instance context shims.
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)
```
+6 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.0",
"version": "1.14.49",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -32,6 +32,11 @@
"bun": "./src/pty/pty.bun.ts",
"node": "./src/pty/pty.node.ts",
"default": "./src/pty/pty.bun.ts"
},
"#httpapi-server": {
"bun": "./src/server/httpapi-server.node.ts",
"node": "./src/server/httpapi-server.node.ts",
"default": "./src/server/httpapi-server.node.ts"
}
},
"devDependencies": {
-1
View File
@@ -244,7 +244,6 @@ for (const item of targets) {
{
name,
version: Script.version,
preferUnplugged: true,
os: [item.os],
cpu: [item.arch],
},
+80 -167
View File
@@ -1,189 +1,102 @@
#!/usr/bin/env node
import childProcess from "child_process"
import fs from "fs"
import os from "os"
import path from "path"
import { createRequire } from "module"
import os from "os"
import { fileURLToPath } from "url"
import { createRequire } from "module"
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"))
const platformMap = {
darwin: "darwin",
linux: "linux",
win32: "windows",
}
const archMap = {
x64: "x64",
arm64: "arm64",
arm: "arm",
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 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")
function findBinary() {
const { platform, arch } = detectPlatformAndArch()
const packageName = `opencode-${platform}-${arch}`
const binaryName = platform === "windows" ? "opencode.exe" : "opencode"
function supportsAvx2() {
if (arch !== "x64") return false
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)
if (platform === "linux") {
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)
try {
return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8"))
fs.linkSync(binaryPath, target)
} catch {
return false
fs.copyFileSync(binaryPath, target)
}
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 {
main()
void main()
} catch (error) {
console.error(error.message)
process.exit(1)
console.error("Postinstall script error:", error.message)
process.exit(0)
}
+3 -13
View File
@@ -32,32 +32,22 @@ console.log("binaries", binaries)
const version = Object.values(binaries)[0]
await $`mkdir -p ./dist/${pkg.name}`
await $`mkdir -p ./dist/${pkg.name}/bin`
await $`cp -r ./bin ./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}.exe`,
[pkg.name]: `./bin/${pkg.name}`,
},
scripts: {
postinstall: "node ./postinstall.mjs",
postinstall: "bun ./postinstall.mjs || node ./postinstall.mjs",
},
version: version,
license: pkg.license,
os: ["darwin", "linux", "win32"],
cpu: ["arm64", "x64"],
optionalDependencies: binaries,
},
null,
@@ -6,6 +6,7 @@ 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:
@@ -17,6 +18,7 @@ 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
@@ -190,6 +192,7 @@ 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
+7 -3
View File
@@ -197,9 +197,13 @@ 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.
It preserves explicit `InstanceRef` / `WorkspaceRef` context for effects run
through the bridge. Plain JS callbacks that need instance data should receive
that data explicitly.
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.
## Testing
@@ -1,13 +1,309 @@
# Instance Context
# Instance context migration
Instance selection is now Effect-provided 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.
Use these APIs:
## Goal
- `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.
End state:
Do not add new ambient instance globals. Promise and callback boundaries should either stay in Effect, use `EffectBridge`, or pass the required context explicitly.
- 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`
@@ -24,6 +24,10 @@ 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.
+50 -53
View File
@@ -64,11 +64,13 @@ 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 context — keep project context explicit through Effect refs
and bridge boundaries.
- `INST` Instance shim — remove ambient `Instance` usage and old ALS
access patterns.
Shrinks: [`src/project/instance.ts`](../../src/project/instance.ts).
- `BRIDGE` Promise/callback interop — keep bridge helpers, but reduce
legacy ALS coupling.
Shrinks: ad hoc Promise/callback re-entry code.
Shrinks: [`src/effect/bridge.ts`](../../src/effect/bridge.ts)
dependency on [`project/instance.ts`](../../src/project/instance.ts).
- `PROC` AppProcess migration — prefer `AppProcess.Service` over raw
process wrappers.
Shrinks: direct spawn callsites and legacy process helpers.
@@ -92,16 +94,19 @@ shapes and sometimes collapse rich errors into opaque strings.
### Problems
- Some expected service failures still use `NamedError.create(...)` or
collapse to `Effect.die(...)`. The storage/worktree/provider-auth
conversions are done; an inventory sweep is needed for the rest.
- HTTP error middleware still guesses status codes from error names —
some entries (e.g. storage `NotFound`, provider auth) can now be
removed, but the middleware overall has not shrunk.
- Some expected service failures still use `NamedError.create(...)`.
- Some expected service failures still become `Effect.die(...)`, which
makes them defects instead of typed, recoverable failures.
- CLI and HTTP boundaries can render structured errors as generic
`Error: SomeName` output.
- HTTP error middleware still guesses status codes from error names like
`Worktree*` or `ProviderAuthValidationFailed`.
- Route handlers and route groups do not consistently declare the public
error body they intend to expose.
- Repeated route error translations do not yet have a clear home: some
should stay inline, some deserve tiny shared mapper helpers.
- Unknown 500s should log full detail server-side while returning a safe
public body.
### Target Shape
@@ -121,33 +126,19 @@ shapes and sometimes collapse rich errors into opaque strings.
- Generic HTTP middleware should shrink; it should not accumulate more
name-based domain knowledge.
### Recently completed
- [x] `RENDER-1` CLI tagged config error rendering (#27256, tests #27257).
- [x] `ERR-1` [`storage/storage.ts`](../../src/storage/storage.ts) typed
`NotFoundError` (#27265) and removal of the server defect fallback
(#27287).
- [x] `ERR-2` [`worktree/index.ts`](../../src/worktree/index.ts) typed
errors (#27296).
- [x] `ERR-3` [`provider/auth.ts`](../../src/provider/auth.ts) typed
validation/oauth errors (#27301).
- [x] `HTTP-1` Unknown-500 details no longer leaked (#27251); follow-up
to stop exposing named defects (#27471).
- [x] Session message reads typed and made effectful (#27269, #27275,
#27280, #27291).
- [x] Session HTTP error contracts tightened (#27308); busy-session
mapping centralized (#27375, #27473).
- [x] Provider init (#27484) and LSP init (#27494) errors typed.
### First PR Candidates
- [ ] `RENDER-1` Fix CLI top-level rendering for typed config errors.
- [ ] `ERR-1` Convert [`storage/storage.ts`](../../src/storage/storage.ts)
not-found errors.
- [ ] `ERR-2` Convert [`worktree/index.ts`](../../src/worktree/index.ts)
errors and remove matching HTTP name checks where possible.
- [ ] `ERR-3` Convert [`provider/auth.ts`](../../src/provider/auth.ts)
validation errors.
- [ ] `HTTP-1` Remove the unknown-500 stack leak from
[`middleware/error.ts`](../../src/server/routes/instance/httpapi/middleware/error.ts).
- [ ] `HTTP-2` Audit one route group for explicit error contracts and
decide which mappings stay inline vs. shared helper.
- [ ] `ERR-4` Sweep remaining `NamedError.create(...)` and
`Effect.die(...)` callsites for expected failures — re-run `git
grep` to build a current inventory.
- [ ] `RENDER-2` Audit CLI and TUI surfaces for any remaining opaque
`Error: Name` rendering of typed errors.
## P1: Tests
@@ -172,24 +163,26 @@ Recently completed:
- [x] Built-in websearch provider selection uses the same runtime flags as
tool visibility.
- [x] Removed global default-plugin disabling from test preload.
- [x] `RF-1` Scout reads routed through runtime flags (#27318).
- [x] `RF-2` Plan-mode prompt read routed through runtime flags (#27320).
- [x] `RF-3` Event-system reads routed through runtime flags (#27323).
- [x] `RF-4` Workspaces reads routed through runtime flags for session
(#27335), sync (#27336), and control-plane (#27337).
- [x] LLM client (#27368) and installation client (#27369) routed
through runtime flags.
- [x] TUI plugin runtime flags simplified (#27506).
- [x] Background-subagents flag moved to RuntimeFlags, then removed
(`refactor(task): use runtime flag for background subagents`,
`refactor(flags): remove background subagents flag`).
Remaining cleanup:
Recommended next PRs:
- [ ] Sweep lingering `Flag.*` reads — many CLI/TUI/config/observability
callsites still import [`flag.ts`](../../../core/src/flag/flag.ts).
Decide per-callsite whether to route through RuntimeFlags, accept
as legitimate env/config boundary, or migrate to typed `Config`.
```text
RF-1 scout consumers ─┐
├─ can run in parallel
RF-2 plan-mode prompt ┘
└─ RF-3 event-system cluster, stacked only if RF-2 still touches prompt.ts
RF-4 workspaces cluster: later, after mutable Flag tests are cleaned up
```
- [ ] `RF-1` Move scout reads in [`agent.ts`](../../src/agent/agent.ts)
and [`reference.ts`](../../src/reference/reference.ts).
- [ ] `RF-2` Move plan-mode prompt read in
[`session/prompt.ts`](../../src/session/prompt.ts).
- [ ] `RF-3` Move event-system reads in session prompt/processor/
compaction and TUI debug plugin.
- [ ] `RF-4` Move workspaces reads in session/sync/control-plane after
tests stop relying on mutable `Flag` timing.
- [ ] Delete [`test/fixture/flag.ts`](../../test/fixture/flag.ts) once
tests no longer mutate `Flag`.
- [ ] Delete [`flag.ts`](../../../core/src/flag/flag.ts) once no packages
@@ -219,13 +212,17 @@ Next PR candidates:
## P4: Instance And Bridge
Instance context migration is complete for the legacy sync shim. Promise and callback interop continues through [`effect/bridge.ts`](../../src/effect/bridge.ts).
[`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.
Current rules:
Goal:
- 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.
- 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.
## Lower Priority Tracks
+5 -3
View File
@@ -39,9 +39,10 @@ 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"
@@ -1093,7 +1094,7 @@ export class Agent implements ACPAgent {
const currentModeId = await (async () => {
if (!availableModes.length) return undefined
const defaultAgent = await ACPRuntime.defaultAgentInfo(directory)
const defaultAgent = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo()))
const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgent.name)?.id ?? availableModes[0].id
this.sessionManager.setMode(sessionId, resolvedModeId)
return resolvedModeId
@@ -1327,7 +1328,8 @@ export class Agent implements ACPAgent {
if (!current) {
this.sessionManager.setModel(session.id, model)
}
const agent = session.modeId ?? (await ACPRuntime.defaultAgentInfo(directory)).name
const agent =
session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo()))).name
const parts: Array<
| { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
-22
View File
@@ -1,22 +0,0 @@
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"
+10 -23
View File
@@ -1,5 +1,4 @@
import { Schema } from "effect"
import { EventV2 } from "@opencode-ai/core/event"
export type Definition<Type extends string = string, Properties extends Schema.Top = Schema.Top> = {
type: Type
@@ -18,28 +17,16 @@ export function define<Type extends string, Properties extends Schema.Top>(
}
export function effectPayloads() {
return [
...registry
.entries()
.map(([type, def]) =>
Schema.Struct({
id: Schema.String,
type: Schema.Literal(type),
properties: def.properties,
}).annotate({ identifier: `Event.${type}` }),
)
.toArray(),
...EventV2.registry
.values()
.map((definition) =>
Schema.Struct({
id: Schema.String,
type: Schema.Literal(definition.type),
properties: definition.data,
}).annotate({ identifier: `Event.${definition.type}` }),
)
.toArray(),
]
return registry
.entries()
.map(([type, def]) =>
Schema.Struct({
id: Schema.String,
type: Schema.Literal(type),
properties: def.properties,
}).annotate({ identifier: `Event.${type}` }),
)
.toArray()
}
export * as BusEvent from "./bus-event"
+13 -7
View File
@@ -1,11 +1,17 @@
import { Instance } from "../project/instance"
import { InstanceRuntime } from "../project/instance-runtime"
import { context } from "../project/instance-context"
import { WithInstance } from "../project/with-instance"
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
const ctx = await InstanceRuntime.load({ directory })
try {
return await context.provide(ctx, cb)
} finally {
await InstanceRuntime.disposeInstance(ctx)
}
return WithInstance.provide({
directory,
fn: async () => {
try {
const result = await cb()
return result
} finally {
await InstanceRuntime.disposeInstance(Instance.current)
}
},
})
}
+4 -4
View File
@@ -28,7 +28,7 @@ const QueryCommand = cmd({
handler: async (args: { query?: string; format: string }) => {
const query = args.query as string | undefined
if (query) {
const db = new BunDatabase(Database.getPath(), { readonly: true })
const db = new BunDatabase(Database.Path, { readonly: true })
try {
const result = db.query(query).all() as Record<string, unknown>[]
if (args.format === "json") {
@@ -47,7 +47,7 @@ const QueryCommand = cmd({
db.close()
return
}
const child = spawn("sqlite3", [Database.getPath()], {
const child = spawn("sqlite3", [Database.Path], {
stdio: "inherit",
})
await new Promise((resolve) => child.on("close", resolve))
@@ -58,7 +58,7 @@ const PathCommand = cmd({
command: "path",
describe: "print the database path",
handler: () => {
console.log(Database.getPath())
console.log(Database.Path)
},
})
@@ -66,7 +66,7 @@ const MigrateCommand = cmd({
command: "migrate",
describe: "migrate JSON data to SQLite (merges with existing data)",
handler: async () => {
const sqlite = new BunDatabase(Database.getPath())
const sqlite = new BunDatabase(Database.Path)
const tty = process.stderr.isTTY
const width = 36
const orange = "\x1b[38;5;214m"
+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-context"
import type { InstanceContext } from "@/project/instance"
export const AgentCommand = effectCmd({
command: "agent <name>",
+3 -3
View File
@@ -1,11 +1,11 @@
import { EOL } from "os"
import { Effect, Layer, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { InstanceServiceMap } from "@opencode-ai/core/instance-layer"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { effectCmd } from "../../effect-cmd"
const Runtime = Layer.mergeAll(LocationServiceMap.layer)
const Runtime = Layer.mergeAll(InstanceServiceMap.layer)
export const V2Command = effectCmd({
command: "v2",
@@ -37,7 +37,7 @@ export const V2Command = effectCmd({
process.stdout.write(JSON.stringify(result, null, 2) + EOL)
},
Effect.provide(
LocationServiceMap.get({
InstanceServiceMap.get({
directory: process.cwd(),
}),
),
@@ -1,20 +1,11 @@
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 + promptOffsetWidth(part.segment)
const next = width + Bun.stringWidth(part.segment)
if (next > offset) return part.index
width = next
}
@@ -22,20 +13,20 @@ function displayOffsetIndex(value: string, offset: number) {
return value.length
}
export function displaySlice(value: string, start = 0, end = promptOffsetWidth(value)) {
export function displaySlice(value: string, start = 0, end = Bun.stringWidth(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 + promptOffsetWidth(part.segment)
const next = width + Bun.stringWidth(part.segment)
if (offset === width || offset < next) return part.segment
width = next
}
}
export function mentionTriggerIndex(value: string, offset = promptOffsetWidth(value)) {
export function mentionTriggerIndex(value: string, offset = Bun.stringWidth(value)) {
const text = displaySlice(value, 0, offset)
const index = text.lastIndexOf("@")
if (index === -1) return
@@ -43,6 +34,6 @@ export function mentionTriggerIndex(value: string, offset = promptOffsetWidth(va
const before = index === 0 ? undefined : text[index - 1]
const query = text.slice(index)
if ((before === undefined || /\s/.test(before)) && !/\s/.test(query)) {
return promptOffsetWidth(text.slice(0, index))
return Bun.stringWidth(text.slice(0, index))
}
}
+2 -3
View File
@@ -17,13 +17,13 @@ import { pathToFileURL } from "url"
import { Effect } from "effect"
import { UI } from "../ui"
import { effectCmd } from "../effect-cmd"
import { Flag } from "@opencode-ai/core/flag/flag"
import { ServerAuth } from "@/server/auth"
import { EOL } from "os"
import { Filesystem } from "@/util/filesystem"
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { Agent } from "@/agent/agent"
import { Permission } from "@/permission"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { FormatError, FormatUnknownError } from "../error"
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin"
@@ -235,7 +235,6 @@ export const RunCommand = effectCmd({
}),
handler: Effect.fn("Cli.run")(function* (args) {
const agentSvc = yield* Agent.Service
const flags = yield* RuntimeFlags.Service
yield* Effect.promise(async () => {
const rawMessage = [...args.message, ...(args["--"] || [])].join(" ")
const thinking = args.interactive ? (args.thinking ?? true) : (args.thinking ?? false)
@@ -447,7 +446,7 @@ export const RunCommand = effectCmd({
async function share(sdk: OpencodeClient, sessionID: string) {
const cfg = await sdk.config.get()
if (!cfg.data) return
if (cfg.data.share !== "auto" && !flags.autoShare && !args.share) return
if (cfg.data.share !== "auto" && !Flag.OPENCODE_AUTO_SHARE && !args.share) return
const res = await sdk.session.share({ sessionID }).catch((error) => {
if (error instanceof Error && error.message.includes("disabled")) {
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
+41 -10
View File
@@ -76,6 +76,8 @@ 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",
@@ -479,15 +481,37 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
dialog.clear()
},
},
...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)
},
})),
...(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)
},
})),
]
: []),
{
name: "model.list",
title: "Switch model",
@@ -802,7 +826,14 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
useBindings(() => ({
enabled: command.matcher,
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
bindings: tuiConfig.keybinds.gather(
"app",
Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
? appBindingCommands
: appBindingCommands.filter(
(c) => !c.startsWith("session.cycle_recent") && !c.startsWith("session.quick_switch"),
),
),
}))
useBindings(() => ({
@@ -31,8 +31,6 @@ 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() }),
@@ -132,18 +130,10 @@ export function DialogSessionList() {
const [browseOrder] = createSignal<string[]>(orderByRecency(sync.data.session))
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 RECENT_LIMIT = 5
const options = createMemo(() => {
const enabled = Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
const today = new Date().toDateString()
const sessionMap = new Map(
sessions()
@@ -154,9 +144,17 @@ export function DialogSessionList() {
const searchResult = searchResults()
const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder()
const pinned = local.session.pinned().filter((id) => sessionMap.has(id))
const dismissed = enabled ? new Set(local.session.dismissedRecent()) : new Set<string>()
const pinned = enabled ? local.session.pinned().filter((id) => sessionMap.has(id)) : []
const pinnedSet = new Set(pinned)
const slotByID = new Map<string, number>(local.session.slots().map((id, i) => [id, i + 1]))
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)
function buildOption(id: string, category: string) {
const x = sessionMap.get(id)
@@ -200,7 +198,7 @@ export function DialogSessionList() {
}
const remaining = displayOrder
.filter((id) => !pinnedSet.has(id))
.filter((id) => !pinnedSet.has(id) && !recentSet.has(id))
.map((id) => {
const x = sessionMap.get(id)
if (!x) return undefined
@@ -209,7 +207,11 @@ export function DialogSessionList() {
})
.filter((x) => x !== undefined)
return [...pinned.map((id) => buildOption(id, "Pinned")).filter((x) => x !== undefined), ...remaining]
return [
...pinned.map((id) => buildOption(id, "Pinned")).filter((x) => x !== undefined),
...recent.map((id) => buildOption(id, "Recent")).filter((x) => x !== undefined),
...remaining,
]
})
onMount(() => {
@@ -234,13 +236,32 @@ export function DialogSessionList() {
dialog.clear()
}}
actions={[
{
command: "session.pin.toggle",
title: "pin/unpin",
onTrigger: (option: { value: string }) => {
local.session.togglePin(option.value)
},
},
...(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.delete",
title: "delete",
@@ -297,13 +318,6 @@ 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,6 +87,9 @@ 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"),
@@ -270,6 +273,9 @@ 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 } from "solid-js"
import { batch, createEffect, createMemo, on } from "solid-js"
import { useSync } from "@tui/context/sync"
import { useTheme } from "@tui/context/theme"
import { useRoute } from "@tui/context/route"
@@ -8,6 +8,7 @@ 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"
@@ -386,9 +387,13 @@ 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")
@@ -404,12 +409,16 @@ 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(() => {
@@ -419,10 +428,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const route = useRoute()
const event = useEvent()
let cycling = false
const slots = createMemo(() => {
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)
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)
})
function prune(sessionID: string) {
@@ -433,6 +451,18 @@ 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()
})
}
@@ -441,6 +471,25 @@ 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
@@ -448,10 +497,19 @@ 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)
@@ -462,12 +520,52 @@ 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 })
},
}
})
@@ -1,67 +0,0 @@
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,6 +1,7 @@
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
@@ -28,6 +29,8 @@ type Shortcuts = {
messagesToggleConceal: TipShortcut
modelCycleRecent: TipShortcut
modelList: TipShortcut
sessionCycleRecent: TipShortcut
sessionCycleRecentReverse: TipShortcut
sessionExport: TipShortcut
sessionInterrupt: TipShortcut
sessionList: TipShortcut
@@ -38,6 +41,7 @@ type Shortcuts = {
sessionQuickSwitch9: TipShortcut
sessionSidebarToggle: TipShortcut
sessionTimeline: TipShortcut
sessionToggleRecent: TipShortcut
statusView: TipShortcut
terminalSuspend: TipShortcut
themeList: TipShortcut
@@ -69,7 +73,6 @@ 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}`
@@ -118,6 +121,8 @@ 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"),
@@ -128,6 +133,7 @@ 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"),
@@ -139,13 +145,8 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
return value ? [value] : []
})
return tips[Math.floor(tipOffset * tips.length)] ?? NO_MODELS_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)
})
const parts = createMemo(() => parse(tip()))
return (
<box flexDirection="row" maxWidth="100%">
@@ -175,12 +176,23 @@ 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, 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,
(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[])
: []),
"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,7 +5,6 @@ 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"
@@ -318,11 +317,7 @@ 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}
completedAt={() => props.message.time.completed}
/>
<AssistantReasoning part={part as SessionMessageAssistantReasoning} subtleSyntax={props.subtleSyntax} />
</Match>
<Match when={part.type === "tool"}>
<AssistantTool part={part as SessionMessageAssistantTool} sessionID={props.sessionID} />
@@ -383,64 +378,30 @@ function AssistantText(props: { part: SessionMessageAssistantText; syntax: Synta
)
}
function AssistantReasoning(props: {
part: SessionMessageAssistantReasoning
subtleSyntax: SyntaxStyle
completedAt: () => number | undefined
}) {
function AssistantReasoning(props: { part: SessionMessageAssistantReasoning; subtleSyntax: SyntaxStyle }) {
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() && 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 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>
)
}
@@ -1067,16 +1067,14 @@ async function load(input: { api: Api; config: TuiConfig.Resolved; dispose?: ()
}
runtime = next
try {
const flags = await Effect.runPromise(
Effect.gen(function* () {
return yield* RuntimeFlags.Service
}).pipe(Effect.provide(RuntimeFlags.defaultLayer)),
)
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
}
const flags = await Effect.runPromise(
RuntimeFlags.Service.use((flags) => Effect.succeed(flags)).pipe(Effect.provide(RuntimeFlags.defaultLayer)),
)
for (const item of internalTuiPlugins(flags)) {
log.info("loading internal tui plugin", { id: item.id })
const entry = loadInternalPlugin(item)
@@ -82,7 +82,6 @@ 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"
@@ -158,7 +157,6 @@ const context = createContext<{
width: number
sessionID: string
conceal: () => boolean
thinkingMode: () => ThinkingMode
showThinking: () => boolean
showTimestamps: () => boolean
showDetails: () => boolean
@@ -216,9 +214,7 @@ export function Session() {
const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto")
const [sidebarOpen, setSidebarOpen] = createSignal(false)
const [conceal, setConceal] = createSignal(true)
const thinking = useThinkingMode()
const thinkingMode = thinking.mode
const showThinking = createMemo(() => thinkingMode() !== "hide")
const [showThinking, setShowThinking] = kv.signal("thinking_visibility", true)
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
const [showAssistantMetadata, _setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
@@ -687,12 +683,7 @@ export function Session() {
},
},
{
title: (() => {
const next = nextThinkingMode(thinkingMode())
if (next === "minimal") return "Switch thinking to minimal"
if (next === "hide") return "Hide thinking"
return "Show thinking"
})(),
title: showThinking() ? "Hide thinking" : "Show thinking",
value: "session.toggle.thinking",
category: "Session",
slash: {
@@ -700,17 +691,7 @@ export function Session() {
aliases: ["toggle-thinking"],
},
run: () => {
// 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()))
setShowThinking((prev) => !prev)
dialog.clear()
},
},
@@ -1105,7 +1086,6 @@ export function Session() {
},
sessionID: route.sessionID,
conceal,
thinkingMode,
showThinking,
showTimestamps,
showDetails,
@@ -1512,77 +1492,32 @@ 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(() => {
// OpenRouter encrypts some reasoning blocks; drop the placeholder.
// Filter out redacted reasoning chunks from OpenRouter
// OpenRouter sends encrypted reasoning data that appears as [REDACTED]
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.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 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>
)
}
@@ -1593,15 +1528,14 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
return (
<Show when={props.part.text.trim()}>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
<markdown
syntaxStyle={syntax()}
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
internalBlockMode="top-level"
syntaxStyle={syntax()}
content={props.part.text.trim()}
tableOptions={{ style: "grid" }}
conceal={ctx.conceal()}
fg={theme.markdownText}
bg={theme.background}
fg={theme.text}
/>
</box>
</Show>
@@ -2079,9 +2013,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
const content = createMemo(() => {
if (!props.input.description) return ""
const description =
props.metadata.background === true ? `${props.input.description} (background)` : props.input.description
let content = [`${Locale.titlecase(props.input.subagent_type ?? "General")} Task — ${description}`]
let content = [`${Locale.titlecase(props.input.subagent_type ?? "General")} Task — ${props.input.description}`]
if (isRunning() && tools().length > 0) {
// content[0] += ` · ${tools().length} toolcalls`
@@ -2093,11 +2025,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
}
if (props.part.state.status === "completed") {
content.push(
props.metadata.background === true
? `${tools().length} toolcalls`
: `${tools().length} toolcalls · ${Locale.duration(duration())}`,
)
content.push(`${tools().length} toolcalls · ${Locale.duration(duration())}`)
}
return content.join("\n")
@@ -1,6 +1,5 @@
import { createStore } from "solid-js/store"
import { createMemo, createSignal, For, Show } from "solid-js"
import { useRenderer } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { selectedForeground, tint, useTheme } from "../../context/theme"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
@@ -13,7 +12,6 @@ import { useBindings } from "../../keymap"
export function QuestionPrompt(props: { request: QuestionRequest }) {
const sdk = useSDK()
const { theme } = useTheme()
const renderer = useRenderer()
const tuiConfig = useTuiConfig()
const questions = createMemo(() => props.request.questions)
@@ -304,10 +302,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
}
onMouseOver={() => setTabHover(index())}
onMouseOut={() => setTabHover(null)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
selectTab(index())
}}
onMouseUp={() => selectTab(index())}
>
<text
fg={
@@ -332,10 +327,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
}
onMouseOver={() => setTabHover("confirm")}
onMouseOut={() => setTabHover(null)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
selectTab(questions().length)
}}
onMouseUp={() => selectTab(questions().length)}
>
<text fg={confirm() ? selectedForeground(theme, theme.accent) : theme.textMuted}>Confirm</text>
</box>
@@ -359,10 +351,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
<box
onMouseOver={() => moveTo(i())}
onMouseDown={() => moveTo(i())}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
selectOption()
}}
onMouseUp={() => selectOption()}
>
<box flexDirection="row">
<box backgroundColor={active() ? theme.backgroundElement : undefined} paddingRight={1}>
@@ -391,10 +380,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
<box
onMouseOver={() => moveTo(options().length)}
onMouseDown={() => moveTo(options().length)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
selectOption()
}}
onMouseUp={() => selectOption()}
>
<box flexDirection="row">
<box backgroundColor={other() ? theme.backgroundElement : undefined} paddingRight={1}>
@@ -38,11 +38,6 @@ 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
}
@@ -339,12 +334,11 @@ 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"))
+7 -2
View File
@@ -2,6 +2,7 @@ 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"
@@ -76,8 +77,12 @@ export const rpc = {
return { url: server.url.toString() }
},
async checkUpgrade(input: { directory: string }) {
await InstanceRuntime.load({ directory: input.directory })
await upgrade().catch(() => {})
await WithInstance.provide({
directory: input.directory,
fn: async () => {
await upgrade().catch(() => {})
},
})
},
async reload() {
await AppRuntime.runPromise(
+10 -1
View File
@@ -3,6 +3,7 @@ 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"
/**
@@ -82,11 +83,19 @@ 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 AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx)))
await Instance.restore(ctx, () =>
AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx))),
)
} finally {
await AppRuntime.runPromise(store.dispose(ctx))
}
+2 -3
View File
@@ -72,9 +72,8 @@ export function FormatError(input: unknown) {
}
// ProviderInitError: { providerID: string }
const providerInit = configData(input, "ProviderInitError")
if (providerInit) {
return `Failed to initialize provider "${stringField(providerInit, "providerID")}". Check credentials and configuration.`
if (NamedError.hasName(input, "ProviderInitError")) {
return `Failed to initialize provider "${(input as ErrorLike).data?.providerID}". Check credentials and configuration.`
}
// ConfigJsonError: { path: string, message?: string }
+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-context"
import type { InstanceContext } from "@/project/instance"
import { SessionID, MessageID } from "@/session/schema"
import { Effect, Layer, Context, Schema } from "effect"
import { Config } from "@/config/config"
+1 -1
View File
@@ -14,7 +14,7 @@ export const Image = Schema.Struct({
description: "Maximum image height before resizing or rejecting the attachment (default: 2000)",
}),
max_base64_bytes: Schema.optional(PositiveInt).annotate({
description: "Maximum base64 payload bytes for an image attachment (default: 5242880)",
description: "Maximum base64 payload bytes for an image attachment (default: 4718592)",
}),
}).annotate({ identifier: "ImageAttachmentConfig" })
export type Image = Schema.Schema.Type<typeof Image>

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