Compare commits

...
Author SHA1 Message Date
Shoubhit Dash 202cc863b4 refactor(flags): migrate claude code prompt flag (#27690) 2026-05-15 14:17:04 +05:30
Shoubhit Dash 22cb0395e2 refactor(flags): migrate external skills flag (#27685) 2026-05-15 13:24:56 +05:30
Shoubhit Dash 2d6bedecd4 refactor(flags): migrate output token max to runtime flags (#27680) 2026-05-15 13:07:35 +05:30
opencode-agent[bot] 2080390ca6 chore: generate 2026-05-15 07:36:10 +00:00
KaguraandSimon Klee 1ac3f09468 fix(watcher): resolve symlinked .git path before subscribing (#27016)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-05-15 09:34:53 +02:00
Aiden Cline ca8f578f2f ci: skip previously cleaned PRs (#27670) 2026-05-15 00:23:09 -05:00
Aiden Cline d59d99665b ci: Automate PR cleanup (#27667) 2026-05-14 23:47:59 -05:00
opencode c43edc5b71 sync release versions for v1.15.0 2026-05-15 04:03:54 +00:00
Dax Raad 7a012cac08 fix(tool): ignore invalid custom tool exports 2026-05-14 23:36:28 -04:00
James Long af06e52708 fix(session): ignore instruction lookup errors (#27656) 2026-05-14 23:12:30 -04:00
Dax Raad f807152724 core: fix event projector lookup to use versioned type keys
Fixes a bug where projectors were stored with Definition object references
as Map keys but lookups were failing due to object identity mismatches.
Now uses versioned type strings as keys, ensuring events are correctly
matched to their projectors during sync processing.

This fixes issues where certain events would not be properly projected
to the read model, causing stale or missing data in views.
2026-05-14 22:52:03 -04:00
Dax Raad b0ea7a5aa7 more test fixes 2026-05-14 22:45:09 -04:00
opencode-agent[bot] 34b1be5bcd chore: generate 2026-05-15 02:34:06 +00:00
Dax Raad 16639eee76 test fixes 2026-05-14 22:32:38 -04:00
opencode-agent[bot] 10c8493940 chore: generate 2026-05-15 02:14:56 +00:00
Dax Raad a50ff72f3f fix: remove debug logging 2026-05-14 22:13:15 -04:00
Dax Raad 5f4e5e6896 more typecheck fixes 2026-05-14 22:05:12 -04:00
Dax Raad f39cf911d7 fix missing event types in sdk 2026-05-14 21:57:25 -04:00
Brendan Allan f179dcbf02 fix(app): only run session.updated archive logic if archive state changes (#27637) 2026-05-15 09:42:47 +08:00
opencode-agent[bot] fd6a8520b8 chore: generate 2026-05-15 00:52:06 +00:00
Dax e11e089e42 Add Effect-native core event system (#27415) 2026-05-15 00:50:23 +00:00
Luna Seemann 73cdba959b feat(desktop): auto-hide menu bar on Linux and Windows (#27618) 2026-05-15 08:48:29 +08:00
opencode 4e7a60dac6 sync release versions for v1.14.51 2026-05-15 00:39:54 +00:00
opencode-agent[bot] e62ebd8fec chore: generate 2026-05-15 00:02:04 +00:00
Kit Langton 195f592640 refactor(server): simplify listener lifecycle (#27413) 2026-05-15 00:00:52 +00:00
opencode-agent[bot] 78769010a1 chore: generate 2026-05-14 23:46:57 +00:00
Kit Langton 4e143e3a3e test(lib): promote pollWithTimeout/awaitWithTimeout helpers (#27626) 2026-05-14 23:45:32 +00:00
opencode-agent[bot] dab567aa2d chore: generate 2026-05-14 23:34:42 +00:00
Kit Langton 9d35b04e13 test(acp): replace fixed sleeps with pollUntil in event-subscription (#27624) 2026-05-14 23:33:06 +00:00
Kit Langton 273ab56949 test(bus): fix flaky subscriber races with readiness latch (#27625) 2026-05-14 19:32:25 -04:00
Kit Langton 302ba0ca0b test(session): de-flake shell-cancel tests by waiting for busy state (#27622) 2026-05-14 19:24:09 -04:00
Shoubhit Dash d35e09f1fc test(workspace): use runtime flags in workspace tests (#27612) 2026-05-15 04:19:39 +05:30
Shoubhit Dash fc34c74567 refactor(flags): move channel db flag to runtime flags (#27615) 2026-05-15 04:09:10 +05:30
Shoubhit Dash cb4f5cdea9 refactor(flags): move auto share to runtime flags (#27611) 2026-05-15 03:58:26 +05:30
nv-kasikritc d34a0194ec feat(provider): add NVIDIA endpoints origin header (#27394) 2026-05-14 17:21:58 -05:00
Shoubhit Dash 43310f4d8c refactor(flags): move embedded web ui flag to runtime flags (#27613) 2026-05-15 03:51:29 +05:30
Shoubhit Dash e22cfa435a refactor(lsp): move ty flag to runtime flags (#27610) 2026-05-15 03:40:30 +05:30
opencode-agent[bot] 93b1ccc029 chore: generate 2026-05-14 22:00:48 +00:00
Shoubhit Dash faca2b90c1 refactor(flags): migrate icon discovery runtime flag (#27609) 2026-05-15 03:24:14 +05:30
Shoubhit Dash 76ff18afde refactor(format): move oxfmt flag to runtime flags (#27608) 2026-05-15 03:03:37 +05:30
opencode-agent[bot] 9914c9af17 chore: generate 2026-05-14 21:32:52 +00:00
Shoubhit Dash f202226bbc refactor(flags): move bash timeout to runtime flags (#27607) 2026-05-15 02:49:14 +05:30
Shoubhit Dash 34198f422c refactor(provider): use runtime flag for experimental models (#27606) 2026-05-15 02:48:01 +05:30
Shoubhit Dash cccdeef294 refactor(flags): migrate claude code skills flag to RuntimeFlags (#27605) 2026-05-15 02:47:26 +05:30
Musa 83c145f889 fix(plugin): scope digitalocean oauth to genai (#27599) 2026-05-14 15:20:34 -05:00
Kit Langton d353a6bc24 fix(worktree): accept missing create payload (#27582) 2026-05-14 14:25:22 -04:00
bo-tato d25cc42d21 docs(app): stale reference to removed multi-edit tool (#27579) 2026-05-14 13:10:01 -05:00
opencode-agent[bot] 6039b894c5 chore: generate 2026-05-14 18:05:32 +00:00
Kit Langton b4fc5ef071 refactor(http-recorder): tighten cassette safety, fix WS leaks + docs (#26730) 2026-05-14 18:03:22 +00:00
opencode-agent[bot] f6c8e35383 chore: generate 2026-05-14 17:58:35 +00:00
Kit Langton 94564f3588 fix(session): prevent double auto-compaction from filterCompacted reorder (#27545) 2026-05-14 13:56:12 -04:00
Kit Langton 855bda8384 test(question): wait on question events (#27124) 2026-05-14 13:23:47 -04:00
opencode-agent[bot] 756488d534 chore: generate 2026-05-14 16:42:18 +00:00
Shoubhit Dash 22de34c4de feat: add experimental background subagents (#27084) 2026-05-14 22:10:15 +05:30
Adam bdb0c16a93 chore: update web stats 2026-05-14 11:26:59 -05:00
Sameer KankuteandCursor 7f7eb2e7f8 fix(provider): remove LiteLLM workarounds ported upstream, requires LiteLLM v1.85.0-rc.2+ (#26819)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 11:26:07 -05:00
opencode-agent[bot] e15fd0bb93 chore: generate 2026-05-14 13:33:44 +00:00
opencode-agent[bot] 8f90697df8 chore: generate 2026-05-14 13:32:19 +00:00
opencode-agent[bot] 17af25d1c1 chore: generate 2026-05-14 13:30:59 +00:00
Kit Langton 3c81326a5e docs(effect): refresh TODO with shipped P0 and RF work (#27536) 2026-05-14 09:29:32 -04:00
opencode-agent[bot] 9f8d8f5b0e chore: generate 2026-05-14 12:40:10 +00:00
OpeOginni 337993d53e feat(desktop): add mcp client registration status and authentication handling (#27525) 2026-05-14 20:38:52 +08:00
Shoubhit Dash e26abd8da9 fix(tool): close shell truncation stream (#27517) 2026-05-14 16:34:42 +05:30
Shoubhit Dash 8c1ce0b80c refactor(flags): simplify tui plugin runtime flags (#27506) 2026-05-14 15:56:02 +05:30
Brendan Allan f8c3f560d4 fix(desktop): await execFilePromise and read stdout properly (#27499) 2026-05-14 17:52:23 +08:00
Shoubhit Dash 7e43d3e3f5 refactor(lsp): type initialize errors (#27494) 2026-05-14 15:20:02 +05:30
opencode-agent[bot] 52db7a76e2 chore: update nix node_modules hashes 2026-05-14 09:28:30 +00:00
Shoubhit Dash be6e7b309e refactor(provider): type init errors (#27484) 2026-05-14 14:48:58 +05:30
Simon Klee 0af242974c deps: Upgrade OpenTUI to 0.2.10 (#27491) 2026-05-14 09:14:03 +00:00
Shoubhit Dash 27ac53aaac fix(server): stop exposing named defects (#27471) 2026-05-14 12:51:05 +05:30
Shoubhit Dash 78015571bf refactor(server): centralize session busy mapping (#27473) 2026-05-14 12:50:36 +05:30
Aiden Cline e76cf967e6 fix(session): finalize interrupted assistant messages (#27254) 2026-05-14 01:19:11 -05:00
opencode-agent[bot] c2723b5ea0 chore: generate 2026-05-14 04:25:32 +00:00
Frederik 9675579796 fix: bug encountered when using azure gpt-5.5 w/ completions api (#26222) 2026-05-13 23:24:16 -05:00
opencode-agent[bot] 4d8368970a chore: update nix node_modules hashes 2026-05-14 04:20:42 +00:00
Nikhil PatelandAiden Cline 2a7af6acd8 fix(tui): preserve text selection on question prompt options (#24988)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-05-13 23:17:59 -05:00
opencode-agent[bot] bfd707abc9 chore: generate 2026-05-14 04:07:30 +00:00
Aiden Cline 981e00971a fix: image resizer wasm loading, reenable image resizing (#26805) 2026-05-13 23:06:07 -05:00
Aiden Cline c50d2b3656 Refactor event HTTP API route modules (#27441) 2026-05-13 22:41:17 -05:00
opencode ddad0988e7 sync release versions for v1.14.50 2026-05-14 03:03:23 +00:00
Kit Langton cda8cc7285 test(httpapi): simplify event stream regression coverage (#27427) 2026-05-14 02:18:40 +00:00
b928a1fff9 fix(httpapi): preserve event stream context (#27425)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: James Long <longster@gmail.com>
2026-05-13 22:02:30 -04:00
Kit Langton 04286d0415 docs(effect): plan Instance deletion path (#27424) 2026-05-13 21:58:54 -04:00
opencode-agent[bot] 33bb33ba90 chore: generate 2026-05-14 01:55:00 +00:00
Sebastian b0ade40265 flip back to markdown renderable (#27421) 2026-05-14 03:53:48 +02:00
Kit Langton 681594b551 refactor(storage): remove not found wire serializer (#27416) 2026-05-13 21:39:50 -04:00
Kit Langton edf7649400 fix(session): type busy errors (#27410) 2026-05-14 01:28:04 +00:00
230 changed files with 6807 additions and 2309 deletions
+50
View File
@@ -0,0 +1,50 @@
name: close-prs
on:
schedule:
- cron: "0 22 * * *" # Daily at 10:00 PM UTC
workflow_dispatch:
inputs:
dry-run:
description: "Log matching PRs without closing them"
type: boolean
default: true
max-close:
description: "Maximum matching PRs to close"
type: string
required: false
default: "50"
jobs:
close:
runs-on: ubuntu-latest
timeout-minutes: 240
permissions:
contents: read
issues: write
pull-requests: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: Close old PRs without enough positive reactions
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
max_close="${{ inputs['max-close'] }}"
if [ -z "$max_close" ]; then
max_close="50"
fi
args=("--threshold" "2" "--age-months" "1" "--sleep-ms" "20000" "--max-close" "$max_close")
if [ "${{ github.event_name }}" = "schedule" ]; then
args+=("--execute")
elif [ "${{ inputs['dry-run'] }}" = "false" ]; then
args+=("--execute")
fi
bun script/github/close-prs.ts "${args[@]}"
-235
View File
@@ -1,235 +0,0 @@
name: close-stale-prs
on:
workflow_dispatch:
inputs:
dryRun:
description: "Log actions without closing PRs"
type: boolean
default: false
schedule:
- cron: "0 6 * * *"
permissions:
contents: read
issues: write
pull-requests: write
jobs:
close-stale-prs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Close inactive PRs
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const DAYS_INACTIVE = 60
const MAX_RETRIES = 3
// Adaptive delay: fast for small batches, slower for large to respect
// GitHub's 80 content-generating requests/minute limit
const SMALL_BATCH_THRESHOLD = 10
const SMALL_BATCH_DELAY_MS = 1000 // 1s for daily operations (≤10 PRs)
const LARGE_BATCH_DELAY_MS = 2000 // 2s for backlog (>10 PRs) = ~30 ops/min, well under 80 limit
const startTime = Date.now()
const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000)
const { owner, repo } = context.repo
const dryRun = context.payload.inputs?.dryRun === "true"
core.info(`Dry run mode: ${dryRun}`)
core.info(`Cutoff date: ${cutoff.toISOString()}`)
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function withRetry(fn, description = 'API call') {
let lastError
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const result = await fn()
return result
} catch (error) {
lastError = error
const isRateLimited = error.status === 403 &&
(error.message?.includes('rate limit') || error.message?.includes('secondary'))
if (!isRateLimited) {
throw error
}
// Parse retry-after header, default to 60 seconds
const retryAfter = error.response?.headers?.['retry-after']
? parseInt(error.response.headers['retry-after'])
: 60
// Exponential backoff: retryAfter * 2^attempt
const backoffMs = retryAfter * 1000 * Math.pow(2, attempt)
core.warning(`${description}: Rate limited (attempt ${attempt + 1}/${MAX_RETRIES}). Waiting ${backoffMs / 1000}s before retry...`)
await sleep(backoffMs)
}
}
core.error(`${description}: Max retries (${MAX_RETRIES}) exceeded`)
throw lastError
}
const query = `
query($owner: String!, $repo: String!, $cursor: String) {
repository(owner: $owner, name: $repo) {
pullRequests(first: 100, states: OPEN, after: $cursor) {
pageInfo {
hasNextPage
endCursor
}
nodes {
number
title
author {
login
}
createdAt
commits(last: 1) {
nodes {
commit {
committedDate
}
}
}
comments(last: 1) {
nodes {
createdAt
}
}
reviews(last: 1) {
nodes {
createdAt
}
}
}
}
}
}
`
const allPrs = []
let cursor = null
let hasNextPage = true
let pageCount = 0
while (hasNextPage) {
pageCount++
core.info(`Fetching page ${pageCount} of open PRs...`)
const result = await withRetry(
() => github.graphql(query, { owner, repo, cursor }),
`GraphQL page ${pageCount}`
)
allPrs.push(...result.repository.pullRequests.nodes)
hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage
cursor = result.repository.pullRequests.pageInfo.endCursor
core.info(`Page ${pageCount}: fetched ${result.repository.pullRequests.nodes.length} PRs (total: ${allPrs.length})`)
// Delay between pagination requests (use small batch delay for reads)
if (hasNextPage) {
await sleep(SMALL_BATCH_DELAY_MS)
}
}
core.info(`Found ${allPrs.length} open pull requests`)
const stalePrs = allPrs.filter((pr) => {
const dates = [
new Date(pr.createdAt),
pr.commits.nodes[0] ? new Date(pr.commits.nodes[0].commit.committedDate) : null,
pr.comments.nodes[0] ? new Date(pr.comments.nodes[0].createdAt) : null,
pr.reviews.nodes[0] ? new Date(pr.reviews.nodes[0].createdAt) : null,
].filter((d) => d !== null)
const lastActivity = dates.sort((a, b) => b.getTime() - a.getTime())[0]
if (!lastActivity || lastActivity > cutoff) {
core.info(`PR #${pr.number} is fresh (last activity: ${lastActivity?.toISOString() || "unknown"})`)
return false
}
core.info(`PR #${pr.number} is STALE (last activity: ${lastActivity.toISOString()})`)
return true
})
if (!stalePrs.length) {
core.info("No stale pull requests found.")
return
}
core.info(`Found ${stalePrs.length} stale pull requests`)
// ============================================
// Close stale PRs
// ============================================
const requestDelayMs = stalePrs.length > SMALL_BATCH_THRESHOLD
? LARGE_BATCH_DELAY_MS
: SMALL_BATCH_DELAY_MS
core.info(`Using ${requestDelayMs}ms delay between operations (${stalePrs.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`)
let closedCount = 0
let skippedCount = 0
for (const pr of stalePrs) {
const issue_number = pr.number
const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.`
if (dryRun) {
core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
continue
}
try {
// Add comment
await withRetry(
() => github.rest.issues.createComment({
owner,
repo,
issue_number,
body: closeComment,
}),
`Comment on PR #${issue_number}`
)
// Close PR
await withRetry(
() => github.rest.pulls.update({
owner,
repo,
pull_number: issue_number,
state: "closed",
}),
`Close PR #${issue_number}`
)
closedCount++
core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
// Delay before processing next PR
await sleep(requestDelayMs)
} catch (error) {
skippedCount++
core.error(`Failed to close PR #${issue_number}: ${error.message}`)
}
}
const elapsed = Math.round((Date.now() - startTime) / 1000)
core.info(`\n========== Summary ==========`)
core.info(`Total open PRs found: ${allPrs.length}`)
core.info(`Stale PRs identified: ${stalePrs.length}`)
core.info(`PRs closed: ${closedCount}`)
core.info(`PRs skipped (errors): ${skippedCount}`)
core.info(`Elapsed time: ${elapsed}s`)
core.info(`=============================`)
+32 -32
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.14.49",
"version": "1.15.0",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -84,7 +84,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.14.49",
"version": "1.15.0",
"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.14.49",
"version": "1.15.0",
"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.14.49",
"version": "1.15.0",
"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.14.49",
"version": "1.15.0",
"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.14.49",
"version": "1.15.0",
"bin": {
"opencode": "./bin/opencode",
},
@@ -253,7 +253,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.14.49",
"version": "1.15.0",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -307,7 +307,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.14.49",
"version": "1.15.0",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -337,7 +337,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.14.49",
"version": "1.15.0",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -353,7 +353,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.14.49",
"version": "1.15.0",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -366,7 +366,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.14.49",
"version": "1.15.0",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -384,7 +384,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.14.49",
"version": "1.15.0",
"bin": {
"opencode": "./bin/opencode",
},
@@ -520,7 +520,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.14.49",
"version": "1.15.0",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -536,9 +536,9 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.2.9",
"@opentui/keymap": ">=0.2.9",
"@opentui/solid": ">=0.2.9",
"@opentui/core": ">=0.2.10",
"@opentui/keymap": ">=0.2.10",
"@opentui/solid": ">=0.2.10",
},
"optionalPeers": [
"@opentui/core",
@@ -558,7 +558,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.14.49",
"version": "1.15.0",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -573,7 +573,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.14.49",
"version": "1.15.0",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -608,7 +608,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.14.49",
"version": "1.15.0",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -657,7 +657,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.14.49",
"version": "1.15.0",
"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.9",
"@opentui/keymap": "0.2.9",
"@opentui/solid": "0.2.9",
"@opentui/core": "0.2.10",
"@opentui/keymap": "0.2.10",
"@opentui/solid": "0.2.10",
"@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.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": ["@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-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-D2ne8Xgyrg71L/9lF7vPh30Sxz6+3yAqpT0m87WiI+040J7sQEyK3YM/7w5JKuVemQ4H54HSPjofrUHjfibjoQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+lbDDj42Og+UtTZEwlHhGXichmOlkxSqn0J+Jqjat5/Tt5oZykj1NZjFIQ7ZSz4Miz7EmZwgYKE2CyOmmm9MoQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ymbbt/wN/vgB8g+kbHospJclVKHq6cdgfEYg9qgsSHp2vqMFBqlQQ692MS3BcZfX9jrKROK7NvC6Hj37X5K/7Q=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-5iAoA0aqMWWAQ93nh8Bb0ipwt9h+tvEFc88+YO9St43uUJ+XrXcmMj3T8wtl6dSu/SN0UoDWNaUMHUmtykiPtg=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-0RIrVe4+42oELHtSJBaaYhngUeMKwSeqfdtKeSwEFwCzrqrNXxCpXQdOo8QvjOKGgng4Smn6O6KM8sgCj4SSPQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-EnrkxgH5K76Oi/Br1UHPZblXG5P60snmtySfnxuVaeECNZrbTkV6BV/A0WoBeWshJweGbx1D+eTF+sEEjQCi8w=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-fjCZP1IOLWm68FYl2PRzFg1vfu226FPfiJsdNtLbhaYF2uEZOB/v1BQph21OKnB7GC7X8GQatvhM5sS3DQ2MSQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.10", "", { "os": "linux", "cpu": "x64" }, "sha512-fI+r3kCPqIxsWwPVGpKUQy4zHK8y+jkDRCwa3UbaUy48RQ44jMuf2RhVhmi4xmCvSc8UPJBbYsw1tLuh9kmXjg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-u8SP3u2QEJqcGIULYZ7Lkht9ss7wcN4/LnMuqt9rPOiCduFn/VW4r8lQCftZ6DRSqyoP9mJ1xLzOSFl98UYyEw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-8F4z2hIRgkVWcr6CMVeJ9N4+1rmURPt2Pq2GBPko8ch6rxHR+a//KD1MfphyuLTHBS1tJ4vfZSWSoiaESImtrA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-un7iSy9XHLwa6ouVpUj3eEGnXfPG50OMUJ2Dt30Jvn2vhNwIU2VO4RGx06l5OUD6GGVpHb0RqmG/384oo9i+HA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.10", "", { "os": "win32", "cpu": "x64" }, "sha512-Ki+qNBlIFW5K2wcG/RHrlPp7yEQKXeiNX3mlje25iwX62Ac5w391HBpOmUjbPoq20McPyDRnhbLfbXQSPtickg=="],
"@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/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/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=="],
"@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=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-QIj9PhOXR/GngV/dPjCF7n5rKro2fcTNGzJ47a41Z2Q=",
"aarch64-linux": "sha256-fQl7BjjTYtRKT3HRVhubaIVww/puUFSTzVV5bTy8II8=",
"aarch64-darwin": "sha256-81IAmdjiYZz8IgMJt0+VxzdOS80gTHc5SendwEW/vD4=",
"x86_64-darwin": "sha256-5OMX4VVBMfEmkYvzd09oksAt5hKkxDs84miO804LBI8="
"x86_64-linux": "sha256-Hw7sVV9rTm6qBMtdwfLIV2QvxvLQY5qrywXzuyYbhcs=",
"aarch64-linux": "sha256-++oXnY7YqrYt0Qv7ZISmoHliARM9qEP8FacqLxGZH1c=",
"aarch64-darwin": "sha256-kZVa0R1YbuvtTzpETqK6ddj4ISje5jBFHBdlynkhW7Q=",
"x86_64-darwin": "sha256-94eagNDa8GGJxF8BsMX2BF5Pa+QTl48lXL1+6HgEn0I="
}
}
+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.9",
"@opentui/keymap": "0.2.9",
"@opentui/solid": "0.2.9",
"@opentui/core": "0.2.10",
"@opentui/keymap": "0.2.10",
"@opentui/solid": "0.2.10",
"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.14.49",
"version": "1.15.0",
"description": "",
"type": "module",
"exports": {
@@ -13,6 +13,7 @@ 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
@@ -31,8 +32,16 @@ export const DialogSelectMcp: Component = () => {
const toggle = useMutation(() => ({
mutationFn: async (name: string) => {
if (sync.data.mcp[name]?.status === "connected") await sdk.client.mcp.disconnect({ name })
else await sdk.client.mcp.connect({ name })
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 })
},
onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))),
}))
@@ -67,7 +76,7 @@ export const DialogSelectMcp: Component = () => {
}
const error = () => {
const s = mcpStatus()
return s?.status === "failed" ? s.error : undefined
if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error
}
const enabled = () => status() === "connected"
return (
@@ -78,9 +87,6 @@ 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,7 +145,15 @@ const useMcpToggleMutation = () => {
return useMutation(() => ({
mutationFn: async (name: string) => {
const status = sync.data.mcp[name]
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ 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 })
},
onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))),
onError: (err) => {
@@ -316,7 +324,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
return (
<button
type="button"
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"
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"
onClick={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name)
@@ -333,7 +341,16 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
status() === "needs_auth" || status() === "needs_client_registration",
}}
/>
<span class="text-14-regular text-text-base truncate flex-1">{name}</span>
<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>
<div onClick={(event) => event.stopPropagation()}>
<Switch
checked={enabled()}
@@ -14,12 +14,14 @@ export function StatusPopover() {
const sync = useSync()
const [shown, setShown] = createSignal(false)
const ready = createMemo(() => server.healthy() === false || sync.data.mcp_ready)
const healthy = createMemo(() => {
const serverHealthy = server.healthy() === true
const mcpIssue = createMemo(() => {
const mcp = Object.values(sync.data.mcp ?? {})
const issue = mcp.some((item) => item.status !== "connected" && item.status !== "disabled")
return serverHealthy && !issue
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 healthy = createMemo(() => server.healthy() === true && !mcpIssue())
return (
<Popover
@@ -41,7 +43,9 @@ export function StatusPopover() {
classList={{
"absolute -top-px -right-px size-1.5 rounded-full": true,
"bg-icon-success-base": ready() && healthy(),
"bg-icon-critical-base": server.healthy() === false || (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-border-weak-base": server.healthy() === undefined || !ready(),
}}
/>
@@ -125,6 +125,7 @@ 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,6 +276,7 @@ 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,6 +276,7 @@ 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,6 +300,7 @@ 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,6 +298,7 @@ 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,6 +282,7 @@ 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",
+2 -1
View File
@@ -306,6 +306,7 @@ 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",
@@ -902,7 +903,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, patches, and multi-edits",
"settings.permissions.tool.edit.description": "Modify files, including edits, writes, and patches",
"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,6 +299,7 @@ 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,6 +277,7 @@ 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,6 +275,7 @@ 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,6 +275,7 @@ 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,6 +302,7 @@ 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,6 +277,7 @@ 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,6 +299,7 @@ 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,6 +299,7 @@ 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,6 +304,7 @@ 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,6 +319,7 @@ 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,6 +299,7 @@ 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.14.49",
"version": "1.15.0",
"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: "150K",
full: "150,000",
compact: "160K",
full: "160,000",
},
},
@@ -22,8 +22,8 @@ export const config = {
// Static stats (used on landing page)
stats: {
contributors: "850",
commits: "11,000",
monthlyUsers: "6.5M",
contributors: "900",
commits: "13,000",
monthlyUsers: "7.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.14.49",
"version": "1.15.0",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.14.49",
"version": "1.15.0",
"$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.14.49",
"version": "1.15.0",
"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.14.49",
"version": "1.15.0",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+17 -8
View File
@@ -5,7 +5,8 @@ import { produce, type Draft } from "immer"
import { ModelV2 } from "./model"
import { PluginV2 } from "./plugin"
import { ProviderV2 } from "./provider"
import { Instance } from "./instance"
import { Location } from "./location"
import { EventV2 } from "./event"
type ProviderRecord = {
provider: ProviderV2.Info
@@ -24,6 +25,15 @@ 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>
@@ -57,10 +67,11 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
yield* Instance.Service
yield* Location.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
@@ -157,14 +168,12 @@ 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,
new ModelV2.Info({ ...updated.model, id: modelID, providerID }),
),
models: HashMap.set(record.models, modelID, next),
})
yield* events.publish(Event.ModelUpdated, { model: resolve(next) })
return
}),
@@ -257,4 +266,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.provide(PluginV2.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer))
+157
View File
@@ -0,0 +1,157 @@
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"
-27
View File
@@ -5,29 +5,14 @@ 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"],
@@ -40,14 +25,10 @@ export const Flag = {
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"],
@@ -61,24 +42,16 @@ export const Flag = {
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_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"),
-12
View File
@@ -1,12 +0,0 @@
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
@@ -1,10 +0,0 @@
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
@@ -0,0 +1,12 @@
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
@@ -0,0 +1,11 @@
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,6 +10,7 @@ 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"
}),
}
}),
@@ -1,12 +1,13 @@
import { SessionID } from "@/session/schema"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { EventV2 } from "./event"
import { FileAttachment, Prompt } from "@opencode-ai/core/session-prompt"
import { Schema } from "effect"
import { EventV2 } from "./event"
import { ModelV2 } from "./model"
import { NonNegativeInt } from "./schema"
import { Session } from "./session"
import { FileAttachment, Prompt } from "./session-prompt"
import { ToolOutput } from "./tool-output"
import { V2Schema } from "./v2-schema"
export { FileAttachment }
import { ToolOutput } from "@opencode-ai/core/tool-output"
import { V2Schema } from "@opencode-ai/core/v2-schema"
import { ModelV2 } from "@opencode-ai/core/model"
export const Source = Schema.Struct({
start: NonNegativeInt,
@@ -15,92 +16,94 @@ export const Source = Schema.Struct({
}).annotate({
identifier: "session.next.event.source",
})
export type Source = Schema.Schema.Type<typeof Source>
export type Source = typeof Source.Type
const Base = {
timestamp: V2Schema.DateTimeUtcFromMillis,
sessionID: SessionID,
sessionID: Session.ID,
}
const options = {
aggregate: "sessionID",
version: 1,
} as const
export const UnknownError = Schema.Struct({
type: Schema.Literal("unknown"),
message: Schema.String,
}).annotate({
identifier: "Session.Error.Unknown",
})
export type UnknownError = Schema.Schema.Type<typeof UnknownError>
export type UnknownError = typeof UnknownError.Type
export const AgentSwitched = EventV2.define({
type: "session.next.agent.switched",
aggregate: "sessionID",
version: 1,
...options,
schema: {
...Base,
agent: Schema.String,
},
})
export type AgentSwitched = Schema.Schema.Type<typeof AgentSwitched>
export type AgentSwitched = typeof AgentSwitched.Type
export const ModelSwitched = EventV2.define({
type: "session.next.model.switched",
aggregate: "sessionID",
version: 1,
...options,
schema: {
...Base,
model: ModelV2.Ref,
},
})
export type ModelSwitched = Schema.Schema.Type<typeof ModelSwitched>
export type ModelSwitched = typeof ModelSwitched.Type
export const Prompted = EventV2.define({
type: "session.next.prompted",
aggregate: "sessionID",
version: 1,
...options,
schema: {
...Base,
prompt: Prompt,
},
})
export type Prompted = Schema.Schema.Type<typeof Prompted>
export type Prompted = typeof Prompted.Type
export const Synthetic = EventV2.define({
type: "session.next.synthetic",
aggregate: "sessionID",
...options,
schema: {
...Base,
text: Schema.String,
},
})
export type Synthetic = Schema.Schema.Type<typeof Synthetic>
export type Synthetic = typeof Synthetic.Type
export namespace Shell {
export const Started = EventV2.define({
type: "session.next.shell.started",
aggregate: "sessionID",
...options,
schema: {
...Base,
callID: Schema.String,
command: Schema.String,
},
})
export type Started = Schema.Schema.Type<typeof Started>
export type Started = typeof Started.Type
export const Ended = EventV2.define({
type: "session.next.shell.ended",
aggregate: "sessionID",
...options,
schema: {
...Base,
callID: Schema.String,
output: Schema.String,
},
})
export type Ended = Schema.Schema.Type<typeof Ended>
export type Ended = typeof Ended.Type
}
export namespace Step {
export const Started = EventV2.define({
type: "session.next.step.started",
aggregate: "sessionID",
...options,
schema: {
...Base,
agent: Schema.String,
@@ -108,11 +111,11 @@ export namespace Step {
snapshot: Schema.String.pipe(Schema.optional),
},
})
export type Started = Schema.Schema.Type<typeof Started>
export type Started = typeof Started.Type
export const Ended = EventV2.define({
type: "session.next.step.ended",
aggregate: "sessionID",
...options,
schema: {
...Base,
finish: Schema.String,
@@ -129,123 +132,123 @@ export namespace Step {
snapshot: Schema.String.pipe(Schema.optional),
},
})
export type Ended = Schema.Schema.Type<typeof Ended>
export type Ended = typeof Ended.Type
export const Failed = EventV2.define({
type: "session.next.step.failed",
aggregate: "sessionID",
...options,
schema: {
...Base,
error: UnknownError,
},
})
export type Failed = Schema.Schema.Type<typeof Failed>
export type Failed = typeof Failed.Type
}
export namespace Text {
export const Started = EventV2.define({
type: "session.next.text.started",
aggregate: "sessionID",
...options,
schema: {
...Base,
},
})
export type Started = Schema.Schema.Type<typeof Started>
export type Started = typeof Started.Type
export const Delta = EventV2.define({
type: "session.next.text.delta",
aggregate: "sessionID",
...options,
schema: {
...Base,
delta: Schema.String,
},
})
export type Delta = Schema.Schema.Type<typeof Delta>
export type Delta = typeof Delta.Type
export const Ended = EventV2.define({
type: "session.next.text.ended",
aggregate: "sessionID",
...options,
schema: {
...Base,
text: Schema.String,
},
})
export type Ended = Schema.Schema.Type<typeof Ended>
export type Ended = typeof Ended.Type
}
export namespace Reasoning {
export const Started = EventV2.define({
type: "session.next.reasoning.started",
aggregate: "sessionID",
...options,
schema: {
...Base,
reasoningID: Schema.String,
},
})
export type Started = Schema.Schema.Type<typeof Started>
export type Started = typeof Started.Type
export const Delta = EventV2.define({
type: "session.next.reasoning.delta",
aggregate: "sessionID",
...options,
schema: {
...Base,
reasoningID: Schema.String,
delta: Schema.String,
},
})
export type Delta = Schema.Schema.Type<typeof Delta>
export type Delta = typeof Delta.Type
export const Ended = EventV2.define({
type: "session.next.reasoning.ended",
aggregate: "sessionID",
...options,
schema: {
...Base,
reasoningID: Schema.String,
text: Schema.String,
},
})
export type Ended = Schema.Schema.Type<typeof Ended>
export type Ended = typeof Ended.Type
}
export namespace Tool {
export namespace Input {
export const Started = EventV2.define({
type: "session.next.tool.input.started",
aggregate: "sessionID",
...options,
schema: {
...Base,
callID: Schema.String,
name: Schema.String,
},
})
export type Started = Schema.Schema.Type<typeof Started>
export type Started = typeof Started.Type
export const Delta = EventV2.define({
type: "session.next.tool.input.delta",
aggregate: "sessionID",
...options,
schema: {
...Base,
callID: Schema.String,
delta: Schema.String,
},
})
export type Delta = Schema.Schema.Type<typeof Delta>
export type Delta = typeof Delta.Type
export const Ended = EventV2.define({
type: "session.next.tool.input.ended",
aggregate: "sessionID",
...options,
schema: {
...Base,
callID: Schema.String,
text: Schema.String,
},
})
export type Ended = Schema.Schema.Type<typeof Ended>
export type Ended = typeof Ended.Type
}
export const Called = EventV2.define({
type: "session.next.tool.called",
aggregate: "sessionID",
...options,
schema: {
...Base,
callID: Schema.String,
@@ -257,11 +260,11 @@ export namespace Tool {
}),
},
})
export type Called = Schema.Schema.Type<typeof Called>
export type Called = typeof Called.Type
export const Progress = EventV2.define({
type: "session.next.tool.progress",
aggregate: "sessionID",
...options,
schema: {
...Base,
callID: Schema.String,
@@ -269,11 +272,11 @@ export namespace Tool {
content: Schema.Array(ToolOutput.Content),
},
})
export type Progress = Schema.Schema.Type<typeof Progress>
export type Progress = typeof Progress.Type
export const Success = EventV2.define({
type: "session.next.tool.success",
aggregate: "sessionID",
...options,
schema: {
...Base,
callID: Schema.String,
@@ -285,11 +288,11 @@ export namespace Tool {
}),
},
})
export type Success = Schema.Schema.Type<typeof Success>
export type Success = typeof Success.Type
export const Failed = EventV2.define({
type: "session.next.tool.failed",
aggregate: "sessionID",
...options,
schema: {
...Base,
callID: Schema.String,
@@ -300,7 +303,7 @@ export namespace Tool {
}),
},
})
export type Failed = Schema.Schema.Type<typeof Failed>
export type Failed = typeof Failed.Type
}
export const RetryError = Schema.Struct({
@@ -313,49 +316,50 @@ export const RetryError = Schema.Struct({
}).annotate({
identifier: "session.next.retry_error",
})
export type RetryError = Schema.Schema.Type<typeof RetryError>
export type RetryError = typeof RetryError.Type
export const Retried = EventV2.define({
type: "session.next.retried",
aggregate: "sessionID",
...options,
schema: {
...Base,
attempt: Schema.Finite,
error: RetryError,
},
})
export type Retried = Schema.Schema.Type<typeof Retried>
export type Retried = typeof Retried.Type
export namespace Compaction {
export const Started = EventV2.define({
type: "session.next.compaction.started",
aggregate: "sessionID",
...options,
schema: {
...Base,
reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]),
},
})
export type Started = Schema.Schema.Type<typeof Started>
export type Started = typeof Started.Type
export const Delta = EventV2.define({
type: "session.next.compaction.delta",
aggregate: "sessionID",
...options,
schema: {
...Base,
text: Schema.String,
},
})
export type Delta = typeof Delta.Type
export const Ended = EventV2.define({
type: "session.next.compaction.ended",
aggregate: "sessionID",
...options,
schema: {
...Base,
text: Schema.String,
include: Schema.String.pipe(Schema.optional),
},
})
export type Ended = Schema.Schema.Type<typeof Ended>
export type Ended = typeof Ended.Type
}
export const All = Schema.Union(
@@ -392,16 +396,7 @@ export const All = Schema.Union(
},
).pipe(Schema.toTaggedUnion("type"))
// user
// assistant
// assistant
// assistant
// user
// compaction marker
// -> text
// assistant
export type Event = Schema.Schema.Type<typeof All>
export type Event = typeof All.Type
export type Type = Event["type"]
export * as SessionEvent from "./session-event"
@@ -1,10 +1,10 @@
import { Schema } from "effect"
import { Prompt } from "@opencode-ai/core/session-prompt"
import { Prompt } from "./session-prompt"
import { SessionEvent } from "./session-event"
import { EventV2 } from "./event"
import { ToolOutput } from "@opencode-ai/core/tool-output"
import { V2Schema } from "@opencode-ai/core/v2-schema"
import { ModelV2 } from "@opencode-ai/core/model"
import { ToolOutput } from "./tool-output"
import { V2Schema } from "./v2-schema"
import { ModelV2 } from "./model"
export const ID = EventV2.ID
export type ID = Schema.Schema.Type<typeof ID>
@@ -20,7 +20,7 @@ const Base = {
export class AgentSwitched extends Schema.Class<AgentSwitched>("Session.Message.AgentSwitched")({
...Base,
type: Schema.Literal("agent-switched"),
agent: SessionEvent.AgentSwitched.fields.data.fields.agent,
agent: SessionEvent.AgentSwitched.data.fields.agent,
}) {}
export class ModelSwitched extends Schema.Class<ModelSwitched>("Session.Message.ModelSwitched")({
@@ -43,16 +43,16 @@ export class User extends Schema.Class<User>("Session.Message.User")({
export class Synthetic extends Schema.Class<Synthetic>("Session.Message.Synthetic")({
...Base,
sessionID: SessionEvent.Synthetic.fields.data.fields.sessionID,
text: SessionEvent.Synthetic.fields.data.fields.text,
sessionID: SessionEvent.Synthetic.data.fields.sessionID,
text: SessionEvent.Synthetic.data.fields.text,
type: Schema.Literal("synthetic"),
}) {}
export class Shell extends Schema.Class<Shell>("Session.Message.Shell")({
...Base,
type: Schema.Literal("shell"),
callID: SessionEvent.Shell.Started.fields.data.fields.callID,
command: SessionEvent.Shell.Started.fields.data.fields.command,
callID: SessionEvent.Shell.Started.data.fields.callID,
command: SessionEvent.Shell.Started.data.fields.command,
output: Schema.String,
time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis,
@@ -130,7 +130,7 @@ export class Assistant extends Schema.Class<Assistant>("Session.Message.Assistan
...Base,
type: Schema.Literal("assistant"),
agent: Schema.String,
model: SessionEvent.Step.Started.fields.data.fields.model,
model: SessionEvent.Step.Started.data.fields.model,
content: AssistantContent.pipe(Schema.Array),
snapshot: Schema.Struct({
start: Schema.String.pipe(Schema.optional),
@@ -147,7 +147,7 @@ export class Assistant extends Schema.Class<Assistant>("Session.Message.Assistan
write: Schema.Finite,
}),
}).pipe(Schema.optional),
error: SessionEvent.Step.Failed.fields.data.fields.error.pipe(Schema.optional),
error: SessionEvent.Step.Failed.data.fields.error.pipe(Schema.optional),
time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis,
completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
@@ -156,7 +156,7 @@ export class Assistant extends Schema.Class<Assistant>("Session.Message.Assistan
export class Compaction extends Schema.Class<Compaction>("Session.Message.Compaction")({
type: Schema.Literal("compaction"),
reason: SessionEvent.Compaction.Started.fields.data.fields.reason,
reason: SessionEvent.Compaction.Started.data.fields.reason,
summary: Schema.String,
include: Schema.String.pipe(Schema.optional),
...Base,
+13
View File
@@ -0,0 +1,13 @@
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
+36 -4
View File
@@ -1,14 +1,21 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Layer, Option } from "effect"
import { DateTime, Effect, Fiber, Layer, Option, Stream } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Instance } from "@opencode-ai/core/instance"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
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 instanceLayer = Layer.succeed(Instance.Service, Instance.Service.of({ directory: "test" }))
const it = testEffect(Catalog.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer), Layer.provide(instanceLayer)))
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),
),
)
describe("CatalogV2", () => {
it.effect("normalizes provider baseURL into endpoint url", () =>
@@ -69,6 +76,31 @@ 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
@@ -0,0 +1,132 @@
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 legacy referer headers only to nvidia", () =>
it.effect("applies NVIDIA tracking headers only to nvidia", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(NvidiaPlugin)
@@ -34,8 +34,60 @@ 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 { Instance } from "@opencode-ai/core/instance"
import { Location } from "@opencode-ai/core/location"
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 instanceLayer = Layer.succeed(Instance.Service, Instance.Service.of({ directory: "test" }))
const locationLayer = Layer.succeed(Location.Service, Location.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(instanceLayer)))),
}).pipe(Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(locationLayer)))),
)
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.14.49",
"version": "1.15.0",
"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 = execFilePromise("where", [appName]).toString()
output = await execFilePromise("where", [appName]).then((r) => r.stdout.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 Path: typeof import("../../../opencode/dist/types/src/node").Database.Path
export const getPath: typeof import("../../../opencode/dist/types/src/node").Database.getPath
export const Client: typeof import("../../../opencode/dist/types/src/node").Database.Client
}
export namespace JsonMigration {
+2
View File
@@ -84,6 +84,7 @@ export function createMainWindow() {
width: state.width,
height: state.height,
show: false,
autoHideMenuBar: true,
title: "OpenCode",
icon: iconPath(),
backgroundColor,
@@ -142,6 +143,7 @@ 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.14.49",
"version": "1.15.0",
"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.14.49"
version = "1.15.0"
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.14.49/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.49/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.49/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/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.14.49/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/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.14.49/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.14.49",
"version": "1.15.0",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+14 -15
View File
@@ -166,11 +166,11 @@ import { Effect } from "effect"
const audit = Effect.gen(function* () {
const cassettes = yield* HttpRecorder.Cassette.Service
const entries = yield* cassettes.list()
const issues = yield* Effect.forEach(entries, (entry) =>
const names = yield* cassettes.list()
const issues = yield* Effect.forEach(names, (name) =>
cassettes
.read(entry.name)
.pipe(Effect.map((interactions) => ({ name: entry.name, findings: HttpRecorder.secretFindings(interactions) }))),
.read(name)
.pipe(Effect.map((interactions) => ({ name, findings: HttpRecorder.secretFindings(interactions) }))),
)
return issues.filter((i) => i.findings.length > 0)
})
@@ -196,14 +196,13 @@ type RecordReplayOptions = {
## Layout
| 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. |
| 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. |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.49",
"version": "1.15.0",
"name": "@opencode-ai/http-recorder",
"type": "module",
"license": "MIT",
+32 -20
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, type SecretFinding } from "./redaction"
import { secretFindings, SecretFindingSchema, 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,13 +14,24 @@ export class CassetteNotFoundError extends Schema.TaggedErrorClass<CassetteNotFo
}
}
export interface AppendResult {
readonly findings: ReadonlyArray<SecretFinding>
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 Interface {
readonly read: (name: string) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError>
readonly append: (name: string, interaction: Interaction, metadata?: CassetteMetadata) => Effect.Effect<AppendResult>
readonly append: (
name: string,
interaction: Interaction,
metadata?: CassetteMetadata,
) => Effect.Effect<void, UnsafeCassetteError>
readonly exists: (name: string) => Effect.Effect<boolean>
readonly list: () => Effect.Effect<ReadonlyArray<string>>
}
@@ -44,6 +55,9 @@ 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> =>
@@ -92,11 +106,9 @@ export const fileSystem = (
entry.findings.push(...secretFindings(interaction))
const cassette = buildCassette(name, entry.interactions, metadata)
const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})]
if (findings.length === 0) {
yield* ensureDirectory(name)
yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie)
}
return { findings }
yield* failIfUnsafe(name, findings)
yield* ensureDirectory(name)
yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie)
}),
exists: (name) =>
fs.access(cassettePath(name)).pipe(
@@ -133,17 +145,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) =>
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) ?? [] }
}),
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)
},
exists: (name) => Effect.sync(() => stored.has(name)),
list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()),
})
+6 -4
View File
@@ -12,7 +12,7 @@ import {
} from "effect/unstable/http"
import * as CassetteService from "./cassette"
import { defaultMatcher, selectSequential, type RequestMatcher } from "./matching"
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder"
import { 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,9 +100,11 @@ export const recordingLayer = (
...captured,
}),
}
yield* appendOrFail(cassetteService, name, interaction, options.metadata).pipe(
Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(transportError(request, error.message))),
)
yield* cassetteService
.append(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),
+1 -2
View File
@@ -7,10 +7,9 @@ export type {
WebSocketFrame,
WebSocketInteraction,
} from "./schema"
export { CassetteNotFoundError, hasCassetteSync } from "./cassette"
export { CassetteNotFoundError, hasCassetteSync, UnsafeCassetteError } 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)
const safeText = (value: unknown) => {
export const safeText = (value: unknown) => {
if (value === undefined) return "undefined"
if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
const text = JSON.stringify(value)
+6 -31
View File
@@ -1,47 +1,22 @@
import { Effect, Ref, Schema, Scope } from "effect"
import { Effect, Ref, Scope } from "effect"
import type * as CassetteService from "./cassette"
import type { CassetteNotFoundError } from "./cassette"
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"
import type { Interaction } from "./schema"
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<ResolvedMode> =>
export const resolveAutoMode = (
cassette: CassetteService.Interface,
name: string,
): Effect.Effect<"record" | "replay" | "passthrough"> =>
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,3 +1,5 @@
import { Schema } from "effect"
export const REDACTED = "[REDACTED]"
const DEFAULT_REDACT_HEADERS = [
@@ -95,8 +97,6 @@ export const redactHeaders = (
)
}
import { Schema } from "effect"
export const SecretFindingSchema = Schema.Struct({
path: Schema.String,
reason: Schema.String,
+17 -16
View File
@@ -1,9 +1,10 @@
import { Effect, Option, Ref, Scope, Stream } from "effect"
import type { Headers } from "effect/unstable/http"
import * as CassetteService from "./cassette"
import { canonicalizeJson, decodeJson } from "./matching"
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder"
import { canonicalizeJson, decodeJson, safeText } from "./matching"
import { 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"
@@ -53,7 +54,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 ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`)
throw new Error(`${message}: expected ${safeText(expected)}, received ${safeText(actual)}`)
})
const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson })
@@ -61,7 +62,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}: ${actual}`)
throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${safeText(actual)}`)
})
const expectedText = decodeFrameText(expected)
if (!asJson) return assertEqual(`WebSocket client frame ${index + 1}`, actual, expectedText)
@@ -98,12 +99,13 @@ export const makeWebSocketExecutor = <E>(
const closeOnce = Effect.gen(function* () {
if (yield* Ref.getAndSet(closed, true)) return
yield* connection.close
yield* appendOrFail(
options.cassette,
options.name,
{ transport: "websocket", open: openSnapshot(request), client, server },
options.metadata,
).pipe(Effect.orDie)
yield* options.cassette
.append(
options.name,
{ transport: "websocket", open: openSnapshot(request), client, server },
options.metadata,
)
.pipe(Effect.orDie)
})
return {
sendText: (message) =>
@@ -111,10 +113,7 @@ export const makeWebSocketExecutor = <E>(
.sendText(message)
.pipe(Effect.tap(() => Effect.sync(() => client.push(encodeFrame(message))))),
messages: connection.messages.pipe(
Stream.map((message) => {
server.push(encodeFrame(message))
return message
}),
Stream.tap((message) => Effect.sync(() => server.push(encodeFrame(message)))),
),
close: closeOnce,
}
@@ -130,20 +129,22 @@ 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 ${request.url}`))
if (!interaction)
return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${redactUrl(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.getAndUpdate(messageIndex, (value) => value + 1)
const current = yield* Ref.get(messageIndex)
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,4 +323,118 @@ 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.14.49",
"version": "1.15.0",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
+1 -6
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.49",
"version": "1.15.0",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -32,11 +32,6 @@
"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": {
+102 -36
View File
@@ -94,19 +94,16 @@ shapes and sometimes collapse rich errors into opaque strings.
### Problems
- 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`.
- 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.
- 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
@@ -126,19 +123,33 @@ 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
@@ -163,26 +174,24 @@ 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`).
Recommended next PRs:
Remaining cleanup:
```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.
- [ ] 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`.
- [ ] 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
@@ -224,6 +233,63 @@ Goal:
explicit context where practical.
- Delete `project/instance.ts` only after ambient Instance coupling is gone.
Important distinction:
- `InstanceState.context`, `InstanceState.directory`, and
`InstanceState.workspaceID` are acceptable inside normal Effect service
code when `InstanceRef` / `WorkspaceRef` are provided by the runtime.
- The deletion blockers are the fallback and callback paths that rely on
ambient ALS: direct `Instance.*` reads, `InstanceState.bind(...)`,
`AppRuntime.runPromise(...)` re-entry from plain JS, and bridge restore
code that installs legacy ALS before invoking callbacks.
Current bottom-up inventory from `dev`:
- Direct `Instance.*` value readers:
[`tool/repo_overview.ts`](../../src/tool/repo_overview.ts),
[`control-plane/adapters/worktree.ts`](../../src/control-plane/adapters/worktree.ts),
[`cli/bootstrap.ts`](../../src/cli/bootstrap.ts).
- `InstanceState.bind(...)` callback boundaries:
[`file/watcher.ts`](../../src/file/watcher.ts) native watcher callback,
[`storage/db.ts`](../../src/storage/db.ts) transaction/effect callbacks,
[`session/llm.ts`](../../src/session/llm.ts) workflow approval callback.
- `AppRuntime.runPromise(...)` / re-entry from plain JS:
[`project/with-instance.ts`](../../src/project/with-instance.ts),
[`project/instance-runtime.ts`](../../src/project/instance-runtime.ts),
[`control-plane/adapters/worktree.ts`](../../src/control-plane/adapters/worktree.ts),
[`cli/effect-cmd.ts`](../../src/cli/effect-cmd.ts), plus global/non-instance
callsites such as CLI upgrade and ACP agent defaults.
- Intentional bridge users to classify, not delete blindly:
workspace adapters in [`control-plane/workspace.ts`](../../src/control-plane/workspace.ts),
MCP, command execution, plugins, pty lifecycle, bus scope cleanup, task
cancellation, and HTTP lifecycle reload/dispose paths.
- Core fallback layer to shrink last:
[`effect/run-service.ts`](../../src/effect/run-service.ts),
[`effect/bridge.ts`](../../src/effect/bridge.ts), and
[`effect/instance-state.ts`](../../src/effect/instance-state.ts).
Recommended PR order:
- [ ] `INST-1` Remove direct `Instance.*` value readers. Start with
`repo_overview`, `worktree` adapter, and `cli/bootstrap`; pass context
explicitly or obtain it from an Effect boundary.
- [ ] `INST-2` Move type-only `InstanceContext` imports from
[`project/instance.ts`](../../src/project/instance.ts) to
[`project/instance-context.ts`](../../src/project/instance-context.ts).
- [ ] `INST-3` Audit each `InstanceState.bind(...)` callback from the inside
out: list what the callback calls (`Bus.publish`, database effects,
permission/session services), then replace ambient capture with explicit
`InstanceRef` / `WorkspaceRef` provision or an `EffectBridge` call.
- [ ] `INST-4` Classify `AppRuntime.runPromise(...)` callsites as global,
instance-scoped with explicit refs, or bridge-required. Eliminate the
instance-scoped callsites that rely on `run-service.attach()` falling
back to `Instance.current`.
- [ ] `INST-5` After consumers are explicit, remove `Instance.current` fallback
from `InstanceState.context` and `run-service.attach()`.
- [ ] `INST-6` Move any remaining `restore` / `bind` compatibility helpers to
the boundary that still needs them, then delete
[`project/instance.ts`](../../src/project/instance.ts).
## Lower Priority Tracks
- `PROC` / `FS` — continue AppProcess and AppFileSystem migrations as
+23 -10
View File
@@ -1,4 +1,5 @@
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
@@ -17,16 +18,28 @@ 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()
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(),
]
}
export * as BusEvent from "./bus-event"
+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.Path, { readonly: true })
const db = new BunDatabase(Database.getPath(), { 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.Path], {
const child = spawn("sqlite3", [Database.getPath()], {
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.Path)
console.log(Database.getPath())
},
})
@@ -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.Path)
const sqlite = new BunDatabase(Database.getPath())
const tty = process.stderr.isTTY
const width = 36
const orange = "\x1b[38;5;214m"
+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 { InstanceServiceMap } from "@opencode-ai/core/instance-layer"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { effectCmd } from "../../effect-cmd"
const Runtime = Layer.mergeAll(InstanceServiceMap.layer)
const Runtime = Layer.mergeAll(LocationServiceMap.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(
InstanceServiceMap.get({
LocationServiceMap.get({
directory: process.cwd(),
}),
),
+3 -2
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,6 +235,7 @@ 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)
@@ -446,7 +447,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" && !Flag.OPENCODE_AUTO_SHARE && !args.share) return
if (cfg.data.share !== "auto" && !flags.autoShare && !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)
@@ -1067,14 +1067,16 @@ 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)
@@ -1528,14 +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}>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
<markdown
syntaxStyle={syntax()}
streaming={true}
internalBlockMode="top-level"
content={props.part.text.trim()}
conceal={ctx.conceal()}
fg={theme.text}
fg={theme.markdownText}
bg={theme.background}
/>
</box>
</Show>
@@ -2013,7 +2013,9 @@ function Task(props: ToolProps<typeof TaskTool>) {
const content = createMemo(() => {
if (!props.input.description) return ""
let content = [`${Locale.titlecase(props.input.subagent_type ?? "General")} Task — ${props.input.description}`]
const description =
props.metadata.background === true ? `${props.input.description} (background)` : props.input.description
let content = [`${Locale.titlecase(props.input.subagent_type ?? "General")} Task — ${description}`]
if (isRunning() && tools().length > 0) {
// content[0] += ` · ${tools().length} toolcalls`
@@ -2025,7 +2027,11 @@ function Task(props: ToolProps<typeof TaskTool>) {
}
if (props.part.state.status === "completed") {
content.push(`${tools().length} toolcalls · ${Locale.duration(duration())}`)
content.push(
props.metadata.background === true
? `${tools().length} toolcalls`
: `${tools().length} toolcalls · ${Locale.duration(duration())}`,
)
}
return content.join("\n")
@@ -1,5 +1,6 @@
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"
@@ -12,6 +13,7 @@ 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)
@@ -302,7 +304,10 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
}
onMouseOver={() => setTabHover(index())}
onMouseOut={() => setTabHover(null)}
onMouseUp={() => selectTab(index())}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
selectTab(index())
}}
>
<text
fg={
@@ -327,7 +332,10 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
}
onMouseOver={() => setTabHover("confirm")}
onMouseOut={() => setTabHover(null)}
onMouseUp={() => selectTab(questions().length)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
selectTab(questions().length)
}}
>
<text fg={confirm() ? selectedForeground(theme, theme.accent) : theme.textMuted}>Confirm</text>
</box>
@@ -351,7 +359,10 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
<box
onMouseOver={() => moveTo(i())}
onMouseDown={() => moveTo(i())}
onMouseUp={() => selectOption()}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
selectOption()
}}
>
<box flexDirection="row">
<box backgroundColor={active() ? theme.backgroundElement : undefined} paddingRight={1}>
@@ -380,7 +391,10 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
<box
onMouseOver={() => moveTo(options().length)}
onMouseDown={() => moveTo(options().length)}
onMouseUp={() => selectOption()}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
selectOption()
}}
>
<box flexDirection="row">
<box backgroundColor={other() ? theme.backgroundElement : undefined} paddingRight={1}>
+3 -2
View File
@@ -72,8 +72,9 @@ export function FormatError(input: unknown) {
}
// ProviderInitError: { providerID: string }
if (NamedError.hasName(input, "ProviderInitError")) {
return `Failed to initialize provider "${(input as ErrorLike).data?.providerID}". Check credentials and configuration.`
const providerInit = configData(input, "ProviderInitError")
if (providerInit) {
return `Failed to initialize provider "${stringField(providerInit, "providerID")}". Check credentials and configuration.`
}
// ConfigJsonError: { path: string, message?: string }
+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: 4718592)",
description: "Maximum base64 payload bytes for an image attachment (default: 5242880)",
}),
}).annotate({ identifier: "ImageAttachmentConfig" })
export type Image = Schema.Schema.Type<typeof Image>
@@ -56,6 +56,8 @@ import { Npm } from "@opencode-ai/core/npm"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import { DataMigration } from "@/data-migration"
import { BackgroundJob } from "@/background/job"
import { EventV2Bridge } from "@/event-v2-bridge"
import { RuntimeFlags } from "@/effect/runtime-flags"
export const AppLayer = Layer.mergeAll(
Npm.defaultLayer,
@@ -83,6 +85,7 @@ export const AppLayer = Layer.mergeAll(
Session.defaultLayer,
SessionStatus.defaultLayer,
BackgroundJob.defaultLayer,
RuntimeFlags.defaultLayer,
SessionRunState.defaultLayer,
SessionProcessor.defaultLayer,
SessionCompaction.defaultLayer,
@@ -109,6 +112,7 @@ export const AppLayer = Layer.mergeAll(
ShareNext.defaultLayer,
SessionShare.defaultLayer,
SyncEvent.defaultLayer,
EventV2Bridge.defaultLayer,
DataMigration.defaultLayer,
).pipe(Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer))
+7 -12
View File
@@ -4,11 +4,12 @@ export interface Runner<A, E = never> {
readonly state: State<A, E>
readonly busy: boolean
readonly ensureRunning: (work: Effect.Effect<A, E>) => Effect.Effect<A, E>
readonly startShell: (work: Effect.Effect<A, E>, ready?: Latch.Latch) => Effect.Effect<A, E>
readonly startShell: (work: Effect.Effect<A, E>, ready?: Latch.Latch) => Effect.Effect<A, E | Busy>
readonly cancel: Effect.Effect<void>
}
export class Cancelled extends Schema.TaggedErrorClass<Cancelled>()("RunnerCancelled", {}) {}
export class Busy extends Schema.TaggedErrorClass<Busy>()("RunnerBusy", {}) {}
interface RunHandle<A, E> {
id: number
@@ -41,12 +42,11 @@ export const make = <A, E = never>(
onIdle?: Effect.Effect<void>
onBusy?: Effect.Effect<void>
onInterrupt?: Effect.Effect<A, E>
busy?: () => never
},
): Runner<A, E> => {
const ref = SynchronizedRef.makeUnsafe<State<A, E>>({ _tag: "Idle" })
const idle = opts?.onIdle ?? Effect.void
const busy = opts?.onBusy ?? Effect.void
const onBusy = opts?.onBusy ?? Effect.void
const onInterrupt = opts?.onInterrupt
let ids = 0
@@ -137,20 +137,15 @@ export const make = <A, E = never>(
}),
).pipe(Effect.flatten)
const startShell = (work: Effect.Effect<A, E>, ready?: Latch.Latch) =>
const startShell = (work: Effect.Effect<A, E>, ready?: Latch.Latch): Effect.Effect<A, E | Busy> =>
SynchronizedRef.modifyEffect(
ref,
Effect.fnUntraced(function* (st) {
if (st._tag !== "Idle") {
return [
Effect.sync(() => {
if (opts?.busy) opts.busy()
throw new Error("Runner is busy")
}),
st,
] as const
const reject: Effect.Effect<A, E | Busy> = Effect.fail(new Busy())
return [reject, st] as const
}
yield* busy
yield* onBusy
const id = next()
const cancelled = yield* Deferred.make<void>()
const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild)
@@ -2,13 +2,30 @@ import { Config, ConfigProvider, Context, Effect, Layer } from "effect"
import { ConfigService } from "@/effect/config-service"
const bool = (name: string) => Config.boolean(name).pipe(Config.withDefault(false))
const positiveInteger = (name: string) =>
Config.number(name).pipe(
Config.map((value) => (Number.isInteger(value) && value > 0 ? value : undefined)),
Config.orElse(() => Config.succeed(undefined)),
)
const experimental = bool("OPENCODE_EXPERIMENTAL")
const enabledByExperimental = (name: string) =>
Config.all({ experimental, enabled: bool(name) }).pipe(Config.map((flags) => flags.experimental || flags.enabled))
export class Service extends ConfigService.Service<Service>()("@opencode/RuntimeFlags", {
autoShare: bool("OPENCODE_AUTO_SHARE"),
pure: bool("OPENCODE_PURE"),
disableDefaultPlugins: bool("OPENCODE_DISABLE_DEFAULT_PLUGINS"),
disableChannelDb: bool("OPENCODE_DISABLE_CHANNEL_DB"),
disableEmbeddedWebUi: bool("OPENCODE_DISABLE_EMBEDDED_WEB_UI"),
disableExternalSkills: bool("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
disableClaudeCodePrompt: Config.all({
broad: bool("OPENCODE_DISABLE_CLAUDE_CODE"),
direct: bool("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
}).pipe(Config.map((flags) => flags.broad || flags.direct)),
disableClaudeCodeSkills: Config.all({
broad: bool("OPENCODE_DISABLE_CLAUDE_CODE"),
direct: bool("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS"),
}).pipe(Config.map((flags) => flags.broad || flags.direct)),
enableExa: Config.all({
experimental,
enabled: bool("OPENCODE_ENABLE_EXA"),
@@ -18,12 +35,19 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
enabled: bool("OPENCODE_ENABLE_PARALLEL"),
legacy: bool("OPENCODE_EXPERIMENTAL_PARALLEL"),
}).pipe(Config.map((flags) => flags.enabled || flags.legacy)),
enableExperimentalModels: bool("OPENCODE_ENABLE_EXPERIMENTAL_MODELS"),
enableQuestionTool: bool("OPENCODE_ENABLE_QUESTION_TOOL"),
experimentalScout: enabledByExperimental("OPENCODE_EXPERIMENTAL_SCOUT"),
experimentalBackgroundSubagents: enabledByExperimental("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS"),
experimentalLspTy: bool("OPENCODE_EXPERIMENTAL_LSP_TY"),
experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
experimentalOxfmt: enabledByExperimental("OPENCODE_EXPERIMENTAL_OXFMT"),
experimentalPlanMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
}) {}
+89
View File
@@ -0,0 +1,89 @@
// Temporary V2 bridge: core events are the publish path, but the rest of
// opencode and the HTTP event stream still expect legacy bus/sync payloads.
// This layer goes away once consumers subscribe to core EventV2 directly.
import { Bus as ProjectBus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { InstanceStore } from "@/project/instance-store"
import { SyncEvent } from "@/sync"
import { EventV2 } from "@opencode-ai/core/event"
import "@opencode-ai/core/catalog"
import "@opencode-ai/core/session-event"
import { Context, Effect, Layer, Option } from "effect"
export function toSyncDefinition<D extends EventV2.Definition>(definition: D) {
const result = {
type: definition.type,
version: definition.version,
aggregate: definition.aggregate,
schema: definition.data,
properties: definition.data,
}
return result as SyncEvent.Definition<D["type"], D["data"], D["data"]>
}
export class Service extends Context.Service<Service, EventV2.Interface>()("@opencode/EventV2Bridge") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
const bus = yield* ProjectBus.Service
const sync = yield* SyncEvent.Service
const publishGlobal = (event: EventV2.Payload) =>
Effect.sync(() => {
GlobalBus.emit("event", {
workspace: event.location?.workspaceID,
payload: {
id: event.id,
type: event.type,
properties: event.data,
},
})
})
const provideEventLocation = <E, R>(event: EventV2.Payload, effect: Effect.Effect<void, E, R>) => {
return Effect.gen(function* () {
const ctx = yield* InstanceRef
if (ctx) return yield* effect
const store = Option.getOrUndefined(yield* Effect.serviceOption(InstanceStore.Service))
if (!event.location?.directory || !store) return yield* publishGlobal(event)
return yield* store.load({ directory: event.location.directory }).pipe(
Effect.flatMap((ctx) => {
const withInstance = effect.pipe(Effect.provideService(InstanceRef, ctx))
if (!event.location?.workspaceID) return withInstance
return withInstance.pipe(Effect.provideService(WorkspaceRef, event.location.workspaceID))
}),
)
})
}
const unsubscribe = yield* events.sync((event) => {
const definition = EventV2.registry.get(event.type)
if (!definition) return Effect.void
const aggregateID = definition.aggregate
? (event.data as Record<string, unknown>)[definition.aggregate]
: undefined
if (definition.version !== undefined && typeof aggregateID === "string") {
return provideEventLocation(event, sync.run(toSyncDefinition(definition), event.data))
}
return provideEventLocation(
event,
bus.publish({ type: definition.type, properties: definition.data }, event.data, { id: event.id }),
)
})
yield* Effect.addFinalizer(() => unsubscribe)
return Service.of(events)
}),
)
export const defaultLayer = layer.pipe(
Layer.provideMerge(EventV2.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(ProjectBus.defaultLayer),
)
export * as EventV2Bridge from "./event-v2-bridge"
+9 -3
View File
@@ -2,7 +2,7 @@ import { Cause, Effect, Layer, Context, Schema } from "effect"
// @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper"
import type ParcelWatcher from "@parcel/watcher"
import { readdir } from "fs/promises"
import { readdir, realpath } from "fs/promises"
import path from "path"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
@@ -131,8 +131,14 @@ export const layer = Layer.effect(
const result = yield* git.run(["rev-parse", "--git-dir"], {
cwd: ctx.worktree,
})
const vcsDir = result.exitCode === 0 ? path.resolve(ctx.worktree, result.text().trim()) : undefined
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
const resolved = result.exitCode === 0 ? path.resolve(ctx.worktree, result.text().trim()) : undefined
const vcsDir = resolved ? yield* Effect.promise(() => realpath(resolved).catch(() => resolved)) : undefined
if (
vcsDir &&
!cfgIgnores.includes(".git") &&
!cfgIgnores.includes(vcsDir) &&
(!resolved || !cfgIgnores.includes(resolved))
) {
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
(entry) => entry !== "HEAD",
)
+4 -3
View File
@@ -3,9 +3,10 @@ import type { InstanceContext } from "../project/instance"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { which } from "../util/which"
import { Flag } from "@opencode-ai/core/flag/flag"
export interface Context extends Pick<InstanceContext, "directory" | "worktree"> {}
export interface Context extends Pick<InstanceContext, "directory" | "worktree"> {
experimentalOxfmt: boolean
}
export interface Info {
name: string
@@ -90,7 +91,7 @@ export const oxfmt: Info = {
},
extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"],
async enabled(context) {
if (!Flag.OPENCODE_EXPERIMENTAL_OXFMT) return false
if (!context.experimentalOxfmt) return false
const items = await Filesystem.findUp("package.json", context.directory, context.worktree)
for (const item of items) {
const json = await Filesystem.readJson<{
+8 -2
View File
@@ -5,6 +5,7 @@ import { InstanceState } from "@/effect/instance-state"
import path from "path"
import { mergeDeep } from "remeda"
import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { errorMessage } from "@/util/error"
import * as Log from "@opencode-ai/core/util/log"
import * as Formatter from "./formatter"
@@ -31,6 +32,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const config = yield* Config.Service
const appProcess = yield* AppProcess.Service
const flags = yield* RuntimeFlags.Service
const state = yield* InstanceState.make(
Effect.fn("Format.state")(function* (ctx) {
@@ -40,7 +42,7 @@ export const layer = Layer.effect(
async function getCommand(item: Formatter.Info) {
let cmd = commands[item.name]
if (cmd === false || cmd === undefined) {
cmd = await item.enabled(ctx)
cmd = await item.enabled({ ...ctx, experimentalOxfmt: flags.experimentalOxfmt })
commands[item.name] = cmd
}
return cmd
@@ -198,6 +200,10 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(AppProcess.defaultLayer))
export const defaultLayer = layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(AppProcess.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
)
export * as Format from "."
+29 -33
View File
@@ -1,21 +1,24 @@
import { Config } from "@/config/config"
import type { MessageV2 } from "@/session/message-v2"
import * as Log from "@opencode-ai/core/util/log"
import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" }
import { Context, Effect, Layer, Schema } from "effect"
import path from "node:path"
import { fileURLToPath } from "node:url"
const MAX_BASE64_BYTES = 4.5 * 1024 * 1024
const MAX_BASE64_BYTES = 5 * 1024 * 1024
const MAX_WIDTH = 2000
const MAX_HEIGHT = 2000
const AUTO_RESIZE = true
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
const log = Log.create({ service: "image" })
export class PhotonUnavailableError extends Schema.TaggedErrorClass<PhotonUnavailableError>()(
"ImagePhotonUnavailableError",
export class ResizerUnavailableError extends Schema.TaggedErrorClass<ResizerUnavailableError>()(
"ImageResizerUnavailableError",
{},
) {
override get message() {
return "Photon image processor is unavailable"
return "Image resizer is unavailable"
}
}
@@ -46,7 +49,7 @@ export class SizeError extends Schema.TaggedErrorClass<SizeError>()("ImageSizeEr
}
}
export type Error = PhotonUnavailableError | InvalidDataUrlError | DecodeError | SizeError
export type Error = ResizerUnavailableError | InvalidDataUrlError | DecodeError | SizeError
export interface Interface {
readonly normalize: (input: MessageV2.FilePart) => Effect.Effect<MessageV2.FilePart, Error>
@@ -59,18 +62,15 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const config = yield* Config.Service
const loadPhoton = yield* Effect.cached(
Effect.promise(async () => {
try {
const photonWasm = (await import("@silvia-odwyer/photon-node/photon_rs_bg.wasm", { with: { type: "file" } }))
.default
// Patched photon-node reads this during module init so Bun compiled binaries use the embedded wasm path.
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH =
photonWasm
return await import("@silvia-odwyer/photon-node")
} catch {
return null
}
}),
Effect.sync(() => {
// Patched photon-node reads this during module init so Bun compiled binaries use the embedded wasm path.
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH =
path.isAbsolute(photonWasm) ? photonWasm : fileURLToPath(new URL(photonWasm, import.meta.url))
}).pipe(
Effect.andThen(() => Effect.tryPromise(() => import("@silvia-odwyer/photon-node"))),
Effect.tapError((error) => Effect.sync(() => log.warn("failed to load photon", { error }))),
Effect.mapError(() => new ResizerUnavailableError()),
),
)
const normalize = Effect.fn("Image.normalize")(function* (input: MessageV2.FilePart) {
@@ -85,30 +85,26 @@ export const layer = Layer.effect(
return yield* new InvalidDataUrlError({ url: input.url })
const base64 = input.url.slice(input.url.indexOf(";base64,") + ";base64,".length)
const photon = yield* loadPhoton
if (!photon) return yield* new PhotonUnavailableError()
const bytes = Buffer.byteLength(base64, "utf8")
const decoded = yield* Effect.sync(() => {
try {
return photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64"))
} catch {
return undefined
}
const photon = yield* loadPhoton
const decoded = yield* Effect.try({
try: () => photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64")),
catch: (error) => {
log.warn("failed to decode image", { error })
return new DecodeError()
},
})
if (!decoded) return yield* new DecodeError()
try {
const originalWidth = decoded.get_width()
const originalHeight = decoded.get_height()
if (
originalWidth <= info.maxWidth &&
originalHeight <= info.maxHeight &&
Buffer.byteLength(base64, "utf8") <= info.maxBase64Bytes
)
if (originalWidth <= info.maxWidth && originalHeight <= info.maxHeight && bytes <= info.maxBase64Bytes)
return input
if (!info.autoResize)
return yield* new SizeError({
bytes: Buffer.byteLength(base64, "utf8"),
bytes,
max: info.maxBase64Bytes,
width: originalWidth,
height: originalHeight,
@@ -159,7 +155,7 @@ export const layer = Layer.effect(
}
return yield* new SizeError({
bytes: Buffer.byteLength(base64, "utf8"),
bytes,
max: info.maxBase64Bytes,
width: originalWidth,
height: originalHeight,
+4 -9
View File
@@ -9,7 +9,6 @@ import { Process } from "@/util/process"
import { LANGUAGE_EXTENSIONS } from "./language"
import { Schema } from "effect"
import type * as LSPServer from "./server"
import { NamedError } from "@opencode-ai/core/util/error"
import { withTimeout } from "../util/timeout"
import { Filesystem } from "@/util/filesystem"
@@ -31,9 +30,10 @@ export type Info = NonNullable<Awaited<ReturnType<typeof create>>>
export type Diagnostic = VSCodeDiagnostic
export const InitializeError = NamedError.create("LSPInitializeError", {
export class InitializeError extends Schema.TaggedErrorClass<InitializeError>()("LSPInitializeError", {
serverID: Schema.String,
})
cause: Schema.optional(Schema.Defect),
}) {}
export const Event = {
Diagnostics: BusEvent.define(
@@ -275,12 +275,7 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
INITIALIZE_TIMEOUT_MS,
).catch((err) => {
logger.error("initialize error", { error: err })
throw new InitializeError(
{ serverID: input.serverID },
{
cause: err,
},
)
throw new InitializeError({ serverID: input.serverID, cause: err })
})
const syncKind = getSyncKind(initialized.capabilities)
+7 -6
View File
@@ -6,13 +6,13 @@ import path from "path"
import { pathToFileURL, fileURLToPath } from "url"
import * as LSPServer from "./server"
import { Config } from "@/config/config"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Process } from "@/util/process"
import { spawn as lspspawn } from "./launch"
import { Effect, Layer, Context, Schema } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { containsPath } from "@/project/instance-context"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { RuntimeFlags } from "@/effect/runtime-flags"
const log = Log.create({ service: "lsp" })
@@ -98,8 +98,8 @@ const kinds = [
SymbolKind.Enum,
]
const filterExperimentalServers = (servers: Record<string, LSPServer.Info>) => {
if (Flag.OPENCODE_EXPERIMENTAL_LSP_TY) {
const filterExperimentalServers = (servers: Record<string, LSPServer.Info>, flags: RuntimeFlags.Info) => {
if (flags.experimentalLspTy) {
if (servers["pyright"]) {
log.info("LSP server pyright is disabled because OPENCODE_EXPERIMENTAL_LSP_TY is enabled")
delete servers["pyright"]
@@ -143,6 +143,7 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const flags = yield* RuntimeFlags.Service
const state = yield* InstanceState.make<State>(
Effect.fn("LSP.state")(function* (ctx) {
@@ -157,7 +158,7 @@ export const layer = Layer.effect(
servers[server.id] = server
}
filterExperimentalServers(servers)
filterExperimentalServers(servers, flags)
if (cfg.lsp !== true) {
for (const [name, item] of Object.entries(cfg.lsp)) {
@@ -217,7 +218,7 @@ export const layer = Layer.effect(
async function schedule(server: LSPServer.Info, root: string, key: string) {
const handle = await server
.spawn(root, ctx)
.spawn(root, ctx, flags)
.then((value) => {
if (!value) s.broken.add(key)
return value
@@ -498,7 +499,7 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer))
export * as Diagnostic from "./diagnostic"
+4 -3
View File
@@ -14,6 +14,7 @@ import { which } from "../util/which"
import { Module } from "@opencode-ai/core/util/module"
import { spawn } from "./launch"
import { Npm } from "@opencode-ai/core/npm"
import type { RuntimeFlags } from "@/effect/runtime-flags"
const log = Log.create({ service: "lsp.server" })
const pathExists = async (p: string) =>
@@ -60,7 +61,7 @@ export interface Info {
extensions: string[]
global?: boolean
root: RootFunction
spawn(root: string, ctx: InstanceContext): Promise<Handle | undefined>
spawn(root: string, ctx: InstanceContext, flags: RuntimeFlags.Info): Promise<Handle | undefined>
}
export const Deno: Info = {
@@ -431,8 +432,8 @@ export const Ty: Info = {
"Pipfile",
"pyrightconfig.json",
]),
async spawn(root) {
if (!Flag.OPENCODE_EXPERIMENTAL_LSP_TY) {
async spawn(root, _ctx, flags) {
if (!flags.experimentalLspTy) {
return undefined
}
+1 -1
View File
@@ -59,7 +59,7 @@ function buildAuthorizeUrl(state: string): string {
response_type: "token",
client_id: DO_OAUTH_CLIENT_ID,
redirect_uri: redirectUri(),
scope: "read write",
scope: "genai:create genai:read",
state,
})
return `${DO_AUTHORIZE_URL}?${params.toString()}`
+5 -2
View File
@@ -19,6 +19,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
import { serviceUse } from "@/effect/service-use"
import { RuntimeFlags } from "@/effect/runtime-flags"
const log = Log.create({ service: "project" })
@@ -130,7 +131,7 @@ type GitResult = { code: number; text: string; stderr: string }
export const layer: Layer.Layer<
Service,
never,
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Bus.Service
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Bus.Service | RuntimeFlags.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
@@ -138,6 +139,7 @@ export const layer: Layer.Layer<
const pathSvc = yield* Path.Path
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const bus = yield* Bus.Service
const flags = yield* RuntimeFlags.Service
const git = Effect.fnUntraced(
function* (args: string[], opts?: { cwd?: string }) {
@@ -282,7 +284,7 @@ export const layer: Layer.Layer<
time: { created: Date.now(), updated: Date.now() },
}
if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope))
if (flags.experimentalIconDiscovery) yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope))
const result: Info = {
...existing,
@@ -505,6 +507,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodePath.layer),
Layer.provide(RuntimeFlags.defaultLayer),
)
export const use = serviceUse(Service)
+28 -19
View File
@@ -12,8 +12,6 @@ import * as ModelsDev from "@opencode-ai/core/models"
import { Auth } from "../auth"
import { Env } from "../env"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Flag } from "@opencode-ai/core/flag/flag"
import { NamedError } from "@opencode-ai/core/util/error"
import { iife } from "@/util/iife"
import { Global } from "@opencode-ai/core/global"
import path from "path"
@@ -28,6 +26,7 @@ import { optionalOmitUndefined } from "@opencode-ai/core/schema"
import * as ProviderTransform from "./transform"
import { ModelID, ProviderID } from "./schema"
import { ModelStatus } from "./model-status"
import { RuntimeFlags } from "@/effect/runtime-flags"
const log = Log.create({ service: "provider" })
@@ -428,13 +427,14 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
},
},
}),
nvidia: () =>
nvidia: (provider) =>
Effect.succeed({
autoload: false,
autoload: provider.source === "config",
options: {
headers: {
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
},
},
}),
@@ -975,7 +975,16 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundErr
}
}
export type Error = ModelNotFoundError
export class InitError extends Schema.TaggedErrorClass<InitError>()("ProviderInitError", {
providerID: ProviderID,
cause: Schema.optional(Schema.Defect),
}) {
static isInstance(input: unknown): input is InitError {
return input instanceof InitError
}
}
export type Error = ModelNotFoundError | InitError
export interface Interface {
readonly list: () => Effect.Effect<Record<ProviderID, Info>>
@@ -1119,18 +1128,18 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
}
}
function suggestionModelIDs(provider: Info | undefined) {
function suggestionModelIDs(provider: Info | undefined, enableExperimentalModels: boolean) {
if (!provider) return []
return Object.keys(provider.models).filter((id) => {
const model = provider.models[id]
if (model.status === "deprecated") return false
if (model.status === "alpha" && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) return false
if (model.status === "alpha" && !enableExperimentalModels) return false
return true
})
}
function modelSuggestions(provider: Info | undefined, modelID: ModelID) {
const available = suggestionModelIDs(provider)
function modelSuggestions(provider: Info | undefined, modelID: ModelID, enableExperimentalModels: boolean) {
const available = suggestionModelIDs(provider, enableExperimentalModels)
const fuzzy = fuzzysort.go(modelID, available, { limit: 3, threshold: -10000 }).map((m) => m.target)
if (fuzzy.length) return fuzzy
const query = modelID
@@ -1151,7 +1160,7 @@ function modelSuggestions(provider: Info | undefined, modelID: ModelID) {
.map((item) => item.id)
}
const layer = Layer.effect(
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
@@ -1160,6 +1169,7 @@ const layer = Layer.effect(
const env = yield* Env.Service
const plugin = yield* Plugin.Service
const modelsDevSvc = yield* ModelsDev.Service
const runtimeFlags = yield* RuntimeFlags.Service
const state = yield* InstanceState.make<State>(() =>
Effect.gen(function* () {
@@ -1452,7 +1462,7 @@ const layer = Layer.effect(
(providerID === ProviderID.openrouter && modelID === "openai/gpt-5-chat")
)
delete provider.models[modelID]
if (model.status === "alpha" && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) delete provider.models[modelID]
if (model.status === "alpha" && !runtimeFlags.enableExperimentalModels) delete provider.models[modelID]
if (model.status === "deprecated") delete provider.models[modelID]
if (
(configProvider?.blacklist && configProvider.blacklist.includes(modelID)) ||
@@ -1634,7 +1644,7 @@ const layer = Layer.effect(
s.sdk.set(key, loaded)
return loaded as SDK
} catch (e) {
throw new InitError({ providerID: model.providerID }, { cause: e })
throw new InitError({ providerID: model.providerID, cause: e })
}
}
@@ -1648,7 +1658,7 @@ const layer = Layer.effect(
if (!provider) {
const catalogProvider = s.catalog[providerID]
const suggestions = catalogProvider
? modelSuggestions(catalogProvider, modelID)
? modelSuggestions(catalogProvider, modelID, runtimeFlags.enableExperimentalModels)
: fuzzysort
.go(providerID, Object.keys({ ...s.catalog, ...s.providers }), { limit: 3, threshold: -10000 })
.map((m) => m.target)
@@ -1657,8 +1667,10 @@ const layer = Layer.effect(
const info = provider.models[modelID]
if (!info) {
const current = modelSuggestions(provider, modelID)
const suggestions = current.length ? current : modelSuggestions(s.catalog[providerID], modelID)
const current = modelSuggestions(provider, modelID, runtimeFlags.enableExperimentalModels)
const suggestions = current.length
? current
: modelSuggestions(s.catalog[providerID], modelID, runtimeFlags.enableExperimentalModels)
return yield* new ModelNotFoundError({ providerID, modelID, suggestions })
}
return info
@@ -1806,6 +1818,7 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(Auth.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(ModelsDev.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
),
)
@@ -1827,8 +1840,4 @@ export function parseModel(model: string) {
}
}
export const InitError = NamedError.create("ProviderInitError", {
providerID: ProviderID,
})
export * as Provider from "./provider"
+9 -14
View File
@@ -4,7 +4,6 @@ import type { JSONSchema7 } from "@ai-sdk/provider"
import type * as Provider from "./provider"
import type * as ModelsDev from "@opencode-ai/core/models"
import { iife } from "@/util/iife"
import { Flag } from "@opencode-ai/core/flag/flag"
type Modality = NonNullable<ModelsDev.Model["modalities"]>["input"][number]
@@ -16,7 +15,7 @@ function mimeToModality(mime: string): Modality | undefined {
return undefined
}
export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000
export const OUTPUT_TOKEN_MAX = 32_000
export function sanitizeSurrogates(content: string) {
return content.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD")
@@ -1126,19 +1125,15 @@ export function options(input: {
result["enable_thinking"] = true
}
if (input.model.api.npm === "@ai-sdk/azure" && input.model.api.id.includes("gpt-5.5")) {
result["reasoningSummary"] = "auto"
return result
}
if (input.model.api.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) {
if (!input.model.api.id.includes("gpt-5-pro")) {
result["reasoningEffort"] = "medium"
// Only inject reasoningSummary for providers that support it natively.
// @ai-sdk/openai-compatible proxies (e.g. LiteLLM) do not understand this
// parameter and return "Unknown parameter: 'reasoningSummary'".
if (
input.model.api.npm === "@ai-sdk/openai" ||
input.model.api.npm === "@ai-sdk/azure" ||
input.model.api.npm === "@ai-sdk/github-copilot"
) {
result["reasoningSummary"] = "auto"
}
result["reasoningSummary"] = "auto"
}
// Only set textVerbosity for non-chat gpt-5.x models
@@ -1255,8 +1250,8 @@ export function providerOptions(model: Provider.Model, options: { [x: string]: a
return { [key]: options }
}
export function maxOutputTokens(model: Provider.Model): number {
return Math.min(model.limit.output, OUTPUT_TOKEN_MAX) || OUTPUT_TOKEN_MAX
export function maxOutputTokens(model: Provider.Model, outputTokenMax = OUTPUT_TOKEN_MAX): number {
return Math.min(model.limit.output, outputTokenMax) || outputTokenMax
}
export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 {
@@ -1,35 +0,0 @@
import { NodeHttpServer } from "@effect/platform-node"
import { Effect, Layer } from "effect"
import { createServer } from "node:http"
import { Service } from "./httpapi-server"
export { Service }
export const name = "node-http-server"
export type Opts = { port: number; hostname: string }
export const layer = (opts: Opts) => {
const server = createServer()
const serverRef = { closeStarted: false, forceStop: false }
const close = server.close.bind(server)
// Keep shutdown owned by NodeHttpServer, but honor listener.stop(true) by
// force-closing active HTTP sockets when its finalizer calls server.close().
server.close = ((callback?: Parameters<typeof server.close>[0]) => {
serverRef.closeStarted = true
const result = close(callback)
if (serverRef.forceStop) server.closeAllConnections()
return result
}) as typeof server.close
return Layer.mergeAll(
NodeHttpServer.layer(() => server, { port: opts.port, host: opts.hostname, gracefulShutdownTimeout: "1 second" }),
Layer.succeed(Service)(
Service.of({
closeAll: Effect.sync(() => {
serverRef.forceStop = true
if (serverRef.closeStarted) server.closeAllConnections()
}),
}),
),
)
}
@@ -1,9 +0,0 @@
import { Context, Effect } from "effect"
export interface Interface {
readonly closeAll: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/HttpApiServer") {}
export * as HttpApiServer from "./httpapi-server"
@@ -0,0 +1,3 @@
import { initProjectors } from "./projectors"
initProjectors()
@@ -24,5 +24,3 @@ export function initProjectors() {
},
})
}
initProjectors()
@@ -4,7 +4,7 @@ import { BusEvent } from "@/bus/bus-event"
import { SyncEvent } from "@/sync"
import { ConfigApi } from "./groups/config"
import { ControlApi } from "./groups/control"
import { EventApi } from "./event"
import { EventApi } from "./groups/event"
import { ExperimentalApi } from "./groups/experimental"
import { FileApi } from "./groups/file"
import { GlobalApi } from "./groups/global"
@@ -1,79 +0,0 @@
import { Bus } from "@/bus"
import * as Log from "@opencode-ai/core/util/log"
import { Effect, Schema } from "effect"
import * as Stream from "effect/Stream"
import { HttpServerResponse } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import * as Sse from "effect/unstable/encoding/Sse"
import { WorkspaceRoutingQuery } from "./middleware/workspace-routing"
const log = Log.create({ service: "server" })
export const EventPaths = {
event: "/event",
} as const
export const EventApi = HttpApi.make("event").add(
HttpApiGroup.make("event")
.add(
HttpApiEndpoint.get("subscribe", EventPaths.event, {
query: WorkspaceRoutingQuery,
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
}).annotateMerge(
OpenApi.annotations({
identifier: "event.subscribe",
summary: "Subscribe to events",
description: "Get events",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "event", description: "Instance event stream route." })),
)
function eventData(data: unknown): Sse.Event {
return {
_tag: "Event",
event: "message",
id: undefined,
data: JSON.stringify(data),
}
}
function eventResponse(bus: Bus.Interface) {
const events = bus.subscribeAll().pipe(Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type))
const heartbeat = Stream.tick("10 seconds").pipe(
Stream.drop(1),
Stream.map(() => ({ id: Bus.createID(), type: "server.heartbeat", properties: {} })),
)
log.info("event connected")
return HttpServerResponse.stream(
Stream.make({ id: Bus.createID(), type: "server.connected", properties: {} }).pipe(
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
Stream.map(eventData),
Stream.pipeThroughChannel(Sse.encode()),
Stream.encodeText,
Stream.ensuring(Effect.sync(() => log.info("event disconnected"))),
),
{
contentType: "text/event-stream",
headers: {
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
"X-Content-Type-Options": "nosniff",
},
},
)
}
export const eventHandlers = HttpApiBuilder.group(EventApi, "event", (handlers) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
return handlers.handleRaw(
"subscribe",
Effect.fn("EventHttpApi.subscribe")(function* () {
return eventResponse(bus)
}),
)
}),
)
@@ -0,0 +1,24 @@
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { WorkspaceRoutingQuery } from "../middleware/workspace-routing"
export const EventPaths = {
event: "/event",
} as const
export const EventApi = HttpApi.make("event").add(
HttpApiGroup.make("event")
.add(
HttpApiEndpoint.get("subscribe", EventPaths.event, {
query: WorkspaceRoutingQuery,
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
}).annotateMerge(
OpenApi.annotations({
identifier: "event.subscribe",
summary: "Subscribe to events",
description: "Get events",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "event", description: "Instance event stream route." })),
)

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