mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 08:46:15 -04:00
Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37f89b7429 | |||
| 321db7a819 | |||
| 6d2219e001 | |||
| dd432e3cde | |||
| 77db212a0a | |||
| f56263791c | |||
| 88363f1ed9 | |||
| c5db39f626 | |||
| b5aed287ca | |||
| 53849bd866 | |||
| e33912bfee | |||
| 548648a3d9 | |||
| 4643e13170 | |||
| 042e6a5c86 | |||
| e36d6a0cbe | |||
| cc9c0b15c7 | |||
| f3b0d3d7ac | |||
| 764c6bc517 | |||
| d441e931f9 | |||
| ad79ad9ea8 | |||
| d6b23fd8f6 | |||
| 5911bd532d | |||
| 2385123f03 | |||
| 09549661e1 | |||
| da495fd2e0 | |||
| 85cd447910 | |||
| 0f31fd631b | |||
| aa07e21945 | |||
| f060874b29 | |||
| f21c582db9 | |||
| 65f96a5851 | |||
| 48122b31cc | |||
| 0df2f5b45f | |||
| 499e8e4b78 | |||
| f33b4455a1 | |||
| a24abd2b11 | |||
| d44bef2107 | |||
| f99339e525 | |||
| 2b0e72ab79 | |||
| 2fdee50b3b | |||
| 48293c5271 | |||
| 0c9cfe923f | |||
| 9975c1ed1c | |||
| ef7d801271 | |||
| eb630075c3 | |||
| a2392ca60d | |||
| f9371eb66c | |||
| fa9a2cb24d | |||
| 2d90f325fc | |||
| c2ffd7cf14 | |||
| 104f5d5a14 | |||
| 1c7c03332e | |||
| 984eefa6f8 | |||
| bf64f8cbb5 | |||
| 727a83aa7a | |||
| e65383810a | |||
| 12b666e2c9 | |||
| eb5ef1c073 | |||
| 356f684186 | |||
| 7b370406a9 | |||
| 202cc863b4 | |||
| 22cb0395e2 | |||
| 2d6bedecd4 | |||
| 2080390ca6 | |||
| 1ac3f09468 | |||
| ca8f578f2f | |||
| d59d99665b | |||
| c43edc5b71 |
@@ -0,0 +1,50 @@
|
||||
name: close-prs
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 22 * * *" # Daily at 10:00 PM UTC
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry-run:
|
||||
description: "Log matching PRs without closing them"
|
||||
type: boolean
|
||||
default: true
|
||||
max-close:
|
||||
description: "Maximum matching PRs to close"
|
||||
type: string
|
||||
required: false
|
||||
default: "50"
|
||||
|
||||
jobs:
|
||||
close:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 240
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Close old PRs without enough positive reactions
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
max_close="${{ inputs['max-close'] }}"
|
||||
if [ -z "$max_close" ]; then
|
||||
max_close="50"
|
||||
fi
|
||||
|
||||
args=("--threshold" "2" "--age-months" "1" "--sleep-ms" "20000" "--max-close" "$max_close")
|
||||
|
||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
args+=("--execute")
|
||||
elif [ "${{ inputs['dry-run'] }}" = "false" ]; then
|
||||
args+=("--execute")
|
||||
fi
|
||||
|
||||
bun script/github/close-prs.ts "${args[@]}"
|
||||
@@ -1,235 +0,0 @@
|
||||
name: close-stale-prs
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dryRun:
|
||||
description: "Log actions without closing PRs"
|
||||
type: boolean
|
||||
default: false
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
close-stale-prs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Close inactive PRs
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const DAYS_INACTIVE = 60
|
||||
const MAX_RETRIES = 3
|
||||
|
||||
// Adaptive delay: fast for small batches, slower for large to respect
|
||||
// GitHub's 80 content-generating requests/minute limit
|
||||
const SMALL_BATCH_THRESHOLD = 10
|
||||
const SMALL_BATCH_DELAY_MS = 1000 // 1s for daily operations (≤10 PRs)
|
||||
const LARGE_BATCH_DELAY_MS = 2000 // 2s for backlog (>10 PRs) = ~30 ops/min, well under 80 limit
|
||||
|
||||
const startTime = Date.now()
|
||||
const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000)
|
||||
const { owner, repo } = context.repo
|
||||
const dryRun = context.payload.inputs?.dryRun === "true"
|
||||
|
||||
core.info(`Dry run mode: ${dryRun}`)
|
||||
core.info(`Cutoff date: ${cutoff.toISOString()}`)
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function withRetry(fn, description = 'API call') {
|
||||
let lastError
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const result = await fn()
|
||||
return result
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
const isRateLimited = error.status === 403 &&
|
||||
(error.message?.includes('rate limit') || error.message?.includes('secondary'))
|
||||
|
||||
if (!isRateLimited) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Parse retry-after header, default to 60 seconds
|
||||
const retryAfter = error.response?.headers?.['retry-after']
|
||||
? parseInt(error.response.headers['retry-after'])
|
||||
: 60
|
||||
|
||||
// Exponential backoff: retryAfter * 2^attempt
|
||||
const backoffMs = retryAfter * 1000 * Math.pow(2, attempt)
|
||||
|
||||
core.warning(`${description}: Rate limited (attempt ${attempt + 1}/${MAX_RETRIES}). Waiting ${backoffMs / 1000}s before retry...`)
|
||||
|
||||
await sleep(backoffMs)
|
||||
}
|
||||
}
|
||||
core.error(`${description}: Max retries (${MAX_RETRIES}) exceeded`)
|
||||
throw lastError
|
||||
}
|
||||
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!, $cursor: String) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequests(first: 100, states: OPEN, after: $cursor) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
nodes {
|
||||
number
|
||||
title
|
||||
author {
|
||||
login
|
||||
}
|
||||
createdAt
|
||||
commits(last: 1) {
|
||||
nodes {
|
||||
commit {
|
||||
committedDate
|
||||
}
|
||||
}
|
||||
}
|
||||
comments(last: 1) {
|
||||
nodes {
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
reviews(last: 1) {
|
||||
nodes {
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const allPrs = []
|
||||
let cursor = null
|
||||
let hasNextPage = true
|
||||
let pageCount = 0
|
||||
|
||||
while (hasNextPage) {
|
||||
pageCount++
|
||||
core.info(`Fetching page ${pageCount} of open PRs...`)
|
||||
|
||||
const result = await withRetry(
|
||||
() => github.graphql(query, { owner, repo, cursor }),
|
||||
`GraphQL page ${pageCount}`
|
||||
)
|
||||
|
||||
allPrs.push(...result.repository.pullRequests.nodes)
|
||||
hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage
|
||||
cursor = result.repository.pullRequests.pageInfo.endCursor
|
||||
|
||||
core.info(`Page ${pageCount}: fetched ${result.repository.pullRequests.nodes.length} PRs (total: ${allPrs.length})`)
|
||||
|
||||
// Delay between pagination requests (use small batch delay for reads)
|
||||
if (hasNextPage) {
|
||||
await sleep(SMALL_BATCH_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
core.info(`Found ${allPrs.length} open pull requests`)
|
||||
|
||||
const stalePrs = allPrs.filter((pr) => {
|
||||
const dates = [
|
||||
new Date(pr.createdAt),
|
||||
pr.commits.nodes[0] ? new Date(pr.commits.nodes[0].commit.committedDate) : null,
|
||||
pr.comments.nodes[0] ? new Date(pr.comments.nodes[0].createdAt) : null,
|
||||
pr.reviews.nodes[0] ? new Date(pr.reviews.nodes[0].createdAt) : null,
|
||||
].filter((d) => d !== null)
|
||||
|
||||
const lastActivity = dates.sort((a, b) => b.getTime() - a.getTime())[0]
|
||||
|
||||
if (!lastActivity || lastActivity > cutoff) {
|
||||
core.info(`PR #${pr.number} is fresh (last activity: ${lastActivity?.toISOString() || "unknown"})`)
|
||||
return false
|
||||
}
|
||||
|
||||
core.info(`PR #${pr.number} is STALE (last activity: ${lastActivity.toISOString()})`)
|
||||
return true
|
||||
})
|
||||
|
||||
if (!stalePrs.length) {
|
||||
core.info("No stale pull requests found.")
|
||||
return
|
||||
}
|
||||
|
||||
core.info(`Found ${stalePrs.length} stale pull requests`)
|
||||
|
||||
// ============================================
|
||||
// Close stale PRs
|
||||
// ============================================
|
||||
const requestDelayMs = stalePrs.length > SMALL_BATCH_THRESHOLD
|
||||
? LARGE_BATCH_DELAY_MS
|
||||
: SMALL_BATCH_DELAY_MS
|
||||
|
||||
core.info(`Using ${requestDelayMs}ms delay between operations (${stalePrs.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`)
|
||||
|
||||
let closedCount = 0
|
||||
let skippedCount = 0
|
||||
|
||||
for (const pr of stalePrs) {
|
||||
const issue_number = pr.number
|
||||
const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.`
|
||||
|
||||
if (dryRun) {
|
||||
core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
// Add comment
|
||||
await withRetry(
|
||||
() => github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body: closeComment,
|
||||
}),
|
||||
`Comment on PR #${issue_number}`
|
||||
)
|
||||
|
||||
// Close PR
|
||||
await withRetry(
|
||||
() => github.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: issue_number,
|
||||
state: "closed",
|
||||
}),
|
||||
`Close PR #${issue_number}`
|
||||
)
|
||||
|
||||
closedCount++
|
||||
core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
|
||||
|
||||
// Delay before processing next PR
|
||||
await sleep(requestDelayMs)
|
||||
} catch (error) {
|
||||
skippedCount++
|
||||
core.error(`Failed to close PR #${issue_number}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed = Math.round((Date.now() - startTime) / 1000)
|
||||
core.info(`\n========== Summary ==========`)
|
||||
core.info(`Total open PRs found: ${allPrs.length}`)
|
||||
core.info(`Stale PRs identified: ${stalePrs.length}`)
|
||||
core.info(`PRs closed: ${closedCount}`)
|
||||
core.info(`PRs skipped (errors): ${skippedCount}`)
|
||||
core.info(`Elapsed time: ${elapsed}s`)
|
||||
core.info(`=============================`)
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
- ci
|
||||
- dev
|
||||
- beta
|
||||
- fix/npm-native-binary-install
|
||||
- snapshot-*
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.3",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -84,7 +84,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.3",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -119,7 +119,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.3",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -146,7 +146,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.3",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -168,7 +168,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.3",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -192,7 +192,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.3",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -253,7 +253,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.3",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -307,7 +307,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.3",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -337,7 +337,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.3",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -353,7 +353,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.3",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -366,7 +366,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.3",
|
||||
"dependencies": {
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
@@ -384,7 +384,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.3",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -520,7 +520,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.3",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -536,9 +536,9 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.2.10",
|
||||
"@opentui/keymap": ">=0.2.10",
|
||||
"@opentui/solid": ">=0.2.10",
|
||||
"@opentui/core": ">=0.2.11",
|
||||
"@opentui/keymap": ">=0.2.11",
|
||||
"@opentui/solid": ">=0.2.11",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
@@ -558,7 +558,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.3",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -573,7 +573,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.3",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -608,7 +608,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.3",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -657,7 +657,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.3",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -721,9 +721,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.2.10",
|
||||
"@opentui/keymap": "0.2.10",
|
||||
"@opentui/solid": "0.2.10",
|
||||
"@opentui/core": "0.2.11",
|
||||
"@opentui/keymap": "0.2.11",
|
||||
"@opentui/solid": "0.2.11",
|
||||
"@pierre/diffs": "1.1.0-beta.18",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -1590,23 +1590,23 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.2.10", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.10", "@opentui/core-darwin-x64": "0.2.10", "@opentui/core-linux-arm64": "0.2.10", "@opentui/core-linux-x64": "0.2.10", "@opentui/core-win32-arm64": "0.2.10", "@opentui/core-win32-x64": "0.2.10" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-oviCtx0jYjc7F8X2b8+0IkQLg6WH47Nwl6CFeZo5dU0k6OpSbTbi07ZleObaiECAp+S1YLhAtVdgzHU7hBZlaw=="],
|
||||
"@opentui/core": ["@opentui/core@0.2.11", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.11", "@opentui/core-darwin-x64": "0.2.11", "@opentui/core-linux-arm64": "0.2.11", "@opentui/core-linux-x64": "0.2.11", "@opentui/core-win32-arm64": "0.2.11", "@opentui/core-win32-x64": "0.2.11" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-X0zLmcDEvMrPzWYp769I7VEVb+og38vaete9tGZXu9HnJgu/paPUUplUT+6denBQccr2qx1rBYV6EtgbBpLEyw=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+lbDDj42Og+UtTZEwlHhGXichmOlkxSqn0J+Jqjat5/Tt5oZykj1NZjFIQ7ZSz4Miz7EmZwgYKE2CyOmmm9MoQ=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-h2MXtE2Cu3XlKVoQMXthnbhleO68zGXkoh/r1Q5pCoZh6RuXqns5/94D/aZThXBWwzPuEoyarMlxxR9OqrpvHw=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-5iAoA0aqMWWAQ93nh8Bb0ipwt9h+tvEFc88+YO9St43uUJ+XrXcmMj3T8wtl6dSu/SN0UoDWNaUMHUmtykiPtg=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-Y0jbPClnOBTPSIy+2THG86MTqIG/jGFlOOKuw4JfCDqEjPBM3pLWIHnJb3WxHRi2LlvfyBxvrUTXWlW6JpI0QQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-EnrkxgH5K76Oi/Br1UHPZblXG5P60snmtySfnxuVaeECNZrbTkV6BV/A0WoBeWshJweGbx1D+eTF+sEEjQCi8w=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-blQyyuTaW4q/OQ3whs7Kt7GCXhBUR5EQHHDdjOqQAr0HYpohUa6sbHMbiBcX2Ehc9ZWwtiaOoWiyZ5YXy2SAvg=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.10", "", { "os": "linux", "cpu": "x64" }, "sha512-fI+r3kCPqIxsWwPVGpKUQy4zHK8y+jkDRCwa3UbaUy48RQ44jMuf2RhVhmi4xmCvSc8UPJBbYsw1tLuh9kmXjg=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-0nEB5+MgzQRYiVcQd1vHXPWNPWGh4JEmQTJKyG3OHnTzPaJ1FVSQ/V71ECyRSl3ymY3F+U0eW9cFgw1hCieK2w=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-8F4z2hIRgkVWcr6CMVeJ9N4+1rmURPt2Pq2GBPko8ch6rxHR+a//KD1MfphyuLTHBS1tJ4vfZSWSoiaESImtrA=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-+KKH77fzm0qF8py9G2pU32DzB1bAgDMfBajrs7gKL5NtSEnknrwfh7hIs/tq41aF6j9zvIzgtykByh26tcjFog=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.10", "", { "os": "win32", "cpu": "x64" }, "sha512-Ki+qNBlIFW5K2wcG/RHrlPp7yEQKXeiNX3mlje25iwX62Ac5w391HBpOmUjbPoq20McPyDRnhbLfbXQSPtickg=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.11", "", { "os": "win32", "cpu": "x64" }, "sha512-dMmb9DX0W0HWadLdgciMbonqIc1xdcKiVmaQSYxw5eGCzFRPZIOrKHByesP+2ipkMuLx85W/MJUFal/lW8XSNg=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.2.10", "", { "dependencies": { "@opentui/core": "0.2.10" }, "peerDependencies": { "@opentui/react": "0.2.10", "@opentui/solid": "0.2.10", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-80fU3Lr/98sNIpVYd8PApAeQw8A8D9BemyOGi6jGvTQCl0rxKgvaVBviDRGKxl1INTVjZy9By8UPncc2KJOuWQ=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.2.11", "", { "dependencies": { "@opentui/core": "0.2.11" }, "peerDependencies": { "@opentui/react": "0.2.11", "@opentui/solid": "0.2.11", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-pCrJrY3mTuXdDaaRneId1JsJCtGE+7prTtWihzOLZzVJTJYyYtT38gMI7MpyAoloVDfEL5cTe8C+v7wv+IYREw=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.2.10", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.10", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-+4/MB90yIQiPwg8Y4wY092yva9BvRTsJeeeEO3e2H7P8k8zxYk4G9bzuhqYLxA9mTVQ+zVDlrmFoPQhT7vpIRw=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.2.11", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.11", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-M3WHxBFORHVE0yqMJYpi9PfjXWlnRTw/LYuBhZaJv0HTo+zTs60P/ukGcwnHDWnMpTGf3BH9x0Yi2dIqjHRY6Q=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
|
||||
@@ -37,16 +37,14 @@
|
||||
node_modules = final.callPackage ./nix/node_modules.nix {
|
||||
inherit rev;
|
||||
};
|
||||
in
|
||||
rec {
|
||||
opencode = final.callPackage ./nix/opencode.nix {
|
||||
inherit node_modules;
|
||||
};
|
||||
desktop = final.callPackage ./nix/desktop.nix {
|
||||
opencode-desktop = final.callPackage ./nix/desktop.nix {
|
||||
inherit opencode;
|
||||
};
|
||||
in
|
||||
{
|
||||
inherit opencode;
|
||||
opencode-desktop = desktop;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -56,16 +54,15 @@
|
||||
node_modules = pkgs.callPackage ./nix/node_modules.nix {
|
||||
inherit rev;
|
||||
};
|
||||
in
|
||||
rec {
|
||||
default = opencode;
|
||||
opencode = pkgs.callPackage ./nix/opencode.nix {
|
||||
inherit node_modules;
|
||||
};
|
||||
desktop = pkgs.callPackage ./nix/desktop.nix {
|
||||
opencode-desktop = pkgs.callPackage ./nix/desktop.nix {
|
||||
inherit opencode;
|
||||
};
|
||||
in
|
||||
{
|
||||
default = opencode;
|
||||
inherit opencode desktop;
|
||||
# Updater derivation with fakeHash - build fails and reveals correct hash
|
||||
node_modules_updater = node_modules.override {
|
||||
hash = pkgs.lib.fakeHash;
|
||||
|
||||
+11
-27
@@ -65,16 +65,15 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => {
|
||||
filters,
|
||||
},
|
||||
],
|
||||
formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 100), DIV($FAILED, $TOTAL), 0)" }],
|
||||
formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 200), DIV($FAILED, $TOTAL), 0)" }],
|
||||
timeRange: 900,
|
||||
}).json
|
||||
}
|
||||
|
||||
const providerHttpErrorsQuery = (product: "go" | "zen") => {
|
||||
const providerHttpErrorsQuery = () => {
|
||||
const filters = [
|
||||
{ column: "provider", op: "exists" },
|
||||
{ column: "user_agent", op: "contains", value: "opencode" },
|
||||
{ column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" },
|
||||
]
|
||||
const successHttpStatus = calculatedField({
|
||||
name: "is_success_http_status",
|
||||
@@ -101,11 +100,15 @@ const providerHttpErrorsQuery = (product: "go" | "zen") => {
|
||||
name: "FAILED",
|
||||
column: failedProviderHttpStatus.name,
|
||||
filterCombination: "AND",
|
||||
filters: [...filters, { column: "event_type", op: "=", value: "llm.error" }],
|
||||
filters: [
|
||||
...filters,
|
||||
{ column: "event_type", op: "=", value: "llm.error" },
|
||||
{ column: "llm.error.code", op: "!=", value: "404" },
|
||||
],
|
||||
},
|
||||
],
|
||||
formulas: [
|
||||
{ name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 50), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" },
|
||||
{ name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 200), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" },
|
||||
],
|
||||
timeRange: 900,
|
||||
}).json
|
||||
@@ -215,29 +218,10 @@ new honeycomb.Trigger("LowModelTpsZen", {
|
||||
],
|
||||
})
|
||||
|
||||
new honeycomb.Trigger("IncreasedProviderHttpErrorsGo", {
|
||||
name: "Increased Provider HTTP Errors [Go]",
|
||||
new honeycomb.Trigger("IncreasedProviderHttpErrors", {
|
||||
name: "Increased Provider HTTP Errors",
|
||||
description,
|
||||
queryJson: providerHttpErrorsQuery("go"),
|
||||
alertType: "on_change",
|
||||
frequency: 300,
|
||||
thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }],
|
||||
recipients: [
|
||||
{
|
||||
id: webhookRecipient.id,
|
||||
notificationDetails: [
|
||||
{
|
||||
variables: [{ name: "type", value: "provider_http_errors" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
new honeycomb.Trigger("IncreasedProviderHttpErrorsZen", {
|
||||
name: "Increased Provider HTTP Errors [Zen]",
|
||||
description,
|
||||
queryJson: providerHttpErrorsQuery("zen"),
|
||||
queryJson: providerHttpErrorsQuery(),
|
||||
alertType: "on_change",
|
||||
frequency: 300,
|
||||
thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }],
|
||||
|
||||
+74
-73
@@ -1,100 +1,101 @@
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
rustPlatform,
|
||||
pkg-config,
|
||||
cargo-tauri,
|
||||
bun,
|
||||
nodejs,
|
||||
cargo,
|
||||
rustc,
|
||||
jq,
|
||||
wrapGAppsHook4,
|
||||
electron_41,
|
||||
makeWrapper,
|
||||
dbus,
|
||||
glib,
|
||||
gtk4,
|
||||
libsoup_3,
|
||||
librsvg,
|
||||
libappindicator,
|
||||
glib-networking,
|
||||
openssl,
|
||||
webkitgtk_4_1,
|
||||
gst_all_1,
|
||||
writableTmpDirAsHomeHook,
|
||||
autoPatchelfHook,
|
||||
opencode,
|
||||
}:
|
||||
rustPlatform.buildRustPackage (finalAttrs: {
|
||||
let
|
||||
electron = electron_41;
|
||||
in
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "opencode-desktop";
|
||||
inherit (opencode)
|
||||
version
|
||||
src
|
||||
node_modules
|
||||
patches
|
||||
;
|
||||
|
||||
cargoRoot = "packages/desktop/src-tauri";
|
||||
cargoLock.lockFile = ../packages/desktop/src-tauri/Cargo.lock;
|
||||
buildAndTestSubdir = finalAttrs.cargoRoot;
|
||||
inherit (opencode) version src node_modules;
|
||||
|
||||
nativeBuildInputs = [
|
||||
pkg-config
|
||||
cargo-tauri.hook
|
||||
bun
|
||||
nodejs # for patchShebangs node_modules
|
||||
cargo
|
||||
rustc
|
||||
jq
|
||||
nodejs
|
||||
makeWrapper
|
||||
] ++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ];
|
||||
|
||||
buildInputs = lib.optionals stdenv.isLinux [
|
||||
dbus
|
||||
glib
|
||||
gtk4
|
||||
libsoup_3
|
||||
librsvg
|
||||
libappindicator
|
||||
glib-networking
|
||||
openssl
|
||||
webkitgtk_4_1
|
||||
gst_all_1.gstreamer
|
||||
gst_all_1.gst-plugins-base
|
||||
gst_all_1.gst-plugins-good
|
||||
gst_all_1.gst-plugins-bad
|
||||
writableTmpDirAsHomeHook
|
||||
] ++ lib.optionals stdenv.hostPlatform.isLinux [
|
||||
autoPatchelfHook
|
||||
];
|
||||
|
||||
strictDeps = true;
|
||||
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
|
||||
(lib.getLib stdenv.cc.cc)
|
||||
];
|
||||
|
||||
env = opencode.env // {
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
|
||||
};
|
||||
|
||||
# https://github.com/electron/electron/issues/31121
|
||||
# mac builds use a .app bundle which doesnt have this issue
|
||||
postPatch = lib.optionalString stdenv.isLinux ''
|
||||
BASE_PATH=packages/desktop
|
||||
FILES=(src/main/windows.ts)
|
||||
for file in "''${FILES[@]}"; do
|
||||
substituteInPlace $BASE_PATH/$file \
|
||||
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
|
||||
done
|
||||
'';
|
||||
|
||||
preBuild = ''
|
||||
cp -a ${finalAttrs.node_modules}/{node_modules,packages} .
|
||||
chmod -R u+w node_modules packages
|
||||
cp -r "${electron.dist}" $HOME/.electron-dist
|
||||
chmod -R u+w $HOME/.electron-dist
|
||||
|
||||
cp -R ${finalAttrs.node_modules}/. .
|
||||
patchShebangs node_modules
|
||||
patchShebangs packages/desktop/node_modules
|
||||
|
||||
mkdir -p packages/desktop/src-tauri/sidecars
|
||||
cp ${opencode}/bin/opencode packages/desktop/src-tauri/sidecars/opencode-cli-${stdenv.hostPlatform.rust.rustcTarget}
|
||||
patchShebangs packages/*/node_modules
|
||||
'';
|
||||
|
||||
# see publish-tauri job in .github/workflows/publish.yml
|
||||
tauriBuildFlags = [
|
||||
"--config"
|
||||
"tauri.prod.conf.json"
|
||||
"--no-sign" # no code signing or auto updates
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
cd packages/desktop
|
||||
|
||||
bun run build
|
||||
npx electron-builder --dir \
|
||||
--config electron-builder.config.ts \
|
||||
--config.mac.identity=null \
|
||||
--config.electronDist="$HOME/.electron-dist"
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase =
|
||||
''
|
||||
runHook preInstall
|
||||
''
|
||||
+ lib.optionalString stdenv.hostPlatform.isDarwin ''
|
||||
mkdir -p $out/Applications
|
||||
mv dist/mac*/*.app $out/Applications
|
||||
makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop
|
||||
''
|
||||
+ lib.optionalString stdenv.hostPlatform.isLinux ''
|
||||
mkdir -p $out/opt/opencode-desktop
|
||||
cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop
|
||||
makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
|
||||
--inherit-argv0 \
|
||||
--set ELECTRON_FORCE_IS_PACKAGED 1 \
|
||||
--add-flags $out/opt/opencode-desktop/resources/app.asar \
|
||||
--add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}"
|
||||
''
|
||||
+ ''
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
autoPatchelfIgnoreMissingDeps = [
|
||||
"libc.musl-x86_64.so.1"
|
||||
];
|
||||
|
||||
# FIXME: workaround for concerns about case insensitive filesystems
|
||||
# should be removed once binary is renamed or decided otherwise
|
||||
# darwin output is a .app bundle so no conflict
|
||||
postFixup = lib.optionalString stdenv.hostPlatform.isLinux ''
|
||||
mv $out/bin/OpenCode $out/bin/opencode-desktop
|
||||
sed -i 's|^Exec=OpenCode$|Exec=opencode-desktop|' $out/share/applications/OpenCode.desktop
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "OpenCode Desktop App";
|
||||
homepage = "https://opencode.ai";
|
||||
license = lib.licenses.mit;
|
||||
mainProgram = "opencode-desktop";
|
||||
inherit (opencode.meta) platforms;
|
||||
inherit (opencode.meta) homepage license platforms;
|
||||
};
|
||||
})
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-Hw7sVV9rTm6qBMtdwfLIV2QvxvLQY5qrywXzuyYbhcs=",
|
||||
"aarch64-linux": "sha256-++oXnY7YqrYt0Qv7ZISmoHliARM9qEP8FacqLxGZH1c=",
|
||||
"aarch64-darwin": "sha256-kZVa0R1YbuvtTzpETqK6ddj4ISje5jBFHBdlynkhW7Q=",
|
||||
"x86_64-darwin": "sha256-94eagNDa8GGJxF8BsMX2BF5Pa+QTl48lXL1+6HgEn0I="
|
||||
"x86_64-linux": "sha256-Ucvyzyq+oYvWglkeowSvb0LgDzkAvaSdq0CdA6jgN6U=",
|
||||
"aarch64-linux": "sha256-SERwZvvN6P8/OwNolHmC0KU9H5laVQm+FD/NNKauZA8=",
|
||||
"aarch64-darwin": "sha256-I1ABwMHkTAntlYyg43w0cW8iPYfZa9MT0In2C7plB5g=",
|
||||
"x86_64-darwin": "sha256-degJTL0RG7QQO8/USgIF//ya7oNmwChTmAoJcpXbIp0="
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -40,7 +40,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
env.MODELS_DEV_API_JSON = "${models-dev}/dist/_api.json";
|
||||
env.OPENCODE_DISABLE_MODELS_FETCH = true;
|
||||
env.OPENCODE_VERSION = finalAttrs.version;
|
||||
env.OPENCODE_CHANNEL = "local";
|
||||
env.OPENCODE_CHANNEL = "prod";
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
@@ -89,11 +89,12 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
|
||||
passthru = {
|
||||
jsonschema = "${placeholder "out"}/share/opencode/schema.json";
|
||||
env = finalAttrs.env;
|
||||
};
|
||||
|
||||
meta = {
|
||||
description = "The open source coding agent";
|
||||
homepage = "https://opencode.ai/";
|
||||
homepage = "https://opencode.ai";
|
||||
license = lib.licenses.mit;
|
||||
mainProgram = "opencode";
|
||||
inherit (node_modules.meta) platforms;
|
||||
|
||||
+3
-3
@@ -35,9 +35,9 @@
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.2.10",
|
||||
"@opentui/keymap": "0.2.10",
|
||||
"@opentui/solid": "0.2.10",
|
||||
"@opentui/core": "0.2.11",
|
||||
"@opentui/keymap": "0.2.11",
|
||||
"@opentui/solid": "0.2.11",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@types/luxon": "3.7.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.3",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.3",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.3",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.3",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -5,15 +5,7 @@ function truthy(key: string) {
|
||||
return value === "true" || value === "1"
|
||||
}
|
||||
|
||||
function number(key: string) {
|
||||
const value = process.env[key]
|
||||
if (!value) return undefined
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
|
||||
}
|
||||
|
||||
const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
|
||||
const OPENCODE_DISABLE_CLAUDE_CODE = truthy("OPENCODE_DISABLE_CLAUDE_CODE")
|
||||
const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
|
||||
|
||||
export const Flag = {
|
||||
@@ -30,21 +22,14 @@ export const Flag = {
|
||||
OPENCODE_DISABLE_TERMINAL_TITLE: truthy("OPENCODE_DISABLE_TERMINAL_TITLE"),
|
||||
OPENCODE_SHOW_TTFD: truthy("OPENCODE_SHOW_TTFD"),
|
||||
OPENCODE_PERMISSION: process.env["OPENCODE_PERMISSION"],
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS"),
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: truthy("OPENCODE_DISABLE_LSP_DOWNLOAD"),
|
||||
OPENCODE_DISABLE_AUTOCOMPACT: truthy("OPENCODE_DISABLE_AUTOCOMPACT"),
|
||||
OPENCODE_DISABLE_MODELS_FETCH: truthy("OPENCODE_DISABLE_MODELS_FETCH"),
|
||||
OPENCODE_DISABLE_MOUSE: truthy("OPENCODE_DISABLE_MOUSE"),
|
||||
OPENCODE_DISABLE_CLAUDE_CODE,
|
||||
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
|
||||
OPENCODE_DISABLE_EXTERNAL_SKILLS: truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
|
||||
OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"],
|
||||
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
|
||||
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
|
||||
OPENCODE_ENABLE_QUESTION_TOOL: truthy("OPENCODE_ENABLE_QUESTION_TOOL"),
|
||||
|
||||
// Experimental
|
||||
OPENCODE_EXPERIMENTAL,
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_FILEWATCHER").pipe(
|
||||
Config.withDefault(false),
|
||||
),
|
||||
@@ -53,22 +38,12 @@ export const Flag = {
|
||||
),
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT:
|
||||
copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"),
|
||||
OPENCODE_ENABLE_EXA: truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA"),
|
||||
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: number("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
|
||||
OPENCODE_EXPERIMENTAL_LSP_TOOL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
|
||||
OPENCODE_EXPERIMENTAL_PLAN_MODE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
|
||||
OPENCODE_EXPERIMENTAL_SCOUT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SCOUT"),
|
||||
OPENCODE_ENABLE_PARALLEL: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"),
|
||||
OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"],
|
||||
OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"],
|
||||
OPENCODE_DB: process.env["OPENCODE_DB"],
|
||||
OPENCODE_SKIP_MIGRATIONS: truthy("OPENCODE_SKIP_MIGRATIONS"),
|
||||
OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"),
|
||||
|
||||
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
|
||||
OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"),
|
||||
OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
|
||||
OPENCODE_EXPERIMENTAL_SESSION_SWITCHING: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SESSION_SWITCHING"),
|
||||
|
||||
// Evaluated at access time (not module load) because tests, the CLI, and
|
||||
// external tooling set these env vars at runtime.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.15.0"
|
||||
version = "1.15.3"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.3/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.3/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.3/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.3/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.3/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.3",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.3",
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.3",
|
||||
"name": "@opencode-ai/llm",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -128,17 +128,8 @@ See `specs/effect/migration.md` for the compact pattern reference and examples.
|
||||
|
||||
Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation rather than storing `Fiber | undefined` or `Promise | undefined` manually. See `specs/effect/migration.md` for the full pattern.
|
||||
|
||||
## Instance.bind — ALS for native callbacks
|
||||
## Callback boundaries
|
||||
|
||||
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called.
|
||||
Use `EffectBridge` for native or external callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, plugin callbacks, etc.) that need to re-enter Effect services with instance/workspace context.
|
||||
|
||||
Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish` or anything that reads `Instance.directory`.
|
||||
|
||||
You do not need it for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers.
|
||||
|
||||
```typescript
|
||||
const cb = Instance.bind((err, evts) => {
|
||||
Bus.publish(MyEvent, { ... })
|
||||
})
|
||||
nativeAddon.subscribe(dir, cb)
|
||||
```
|
||||
Plain async code should pass explicit context or stay inside an Effect fiber; do not add ambient instance context shims.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.3",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -244,6 +244,7 @@ for (const item of targets) {
|
||||
{
|
||||
name,
|
||||
version: Script.version,
|
||||
preferUnplugged: true,
|
||||
os: [item.os],
|
||||
cpu: [item.arch],
|
||||
},
|
||||
|
||||
@@ -1,102 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import childProcess from "child_process"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { fileURLToPath } from "url"
|
||||
import path from "path"
|
||||
import { createRequire } from "module"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const require = createRequire(import.meta.url)
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8"))
|
||||
|
||||
function detectPlatformAndArch() {
|
||||
// Map platform names
|
||||
let platform
|
||||
switch (os.platform()) {
|
||||
case "darwin":
|
||||
platform = "darwin"
|
||||
break
|
||||
case "linux":
|
||||
platform = "linux"
|
||||
break
|
||||
case "win32":
|
||||
platform = "windows"
|
||||
break
|
||||
default:
|
||||
platform = os.platform()
|
||||
break
|
||||
}
|
||||
|
||||
// Map architecture names
|
||||
let arch
|
||||
switch (os.arch()) {
|
||||
case "x64":
|
||||
arch = "x64"
|
||||
break
|
||||
case "arm64":
|
||||
arch = "arm64"
|
||||
break
|
||||
case "arm":
|
||||
arch = "arm"
|
||||
break
|
||||
default:
|
||||
arch = os.arch()
|
||||
break
|
||||
}
|
||||
|
||||
return { platform, arch }
|
||||
const platformMap = {
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
win32: "windows",
|
||||
}
|
||||
const archMap = {
|
||||
x64: "x64",
|
||||
arm64: "arm64",
|
||||
arm: "arm",
|
||||
}
|
||||
|
||||
function findBinary() {
|
||||
const { platform, arch } = detectPlatformAndArch()
|
||||
const packageName = `opencode-${platform}-${arch}`
|
||||
const binaryName = platform === "windows" ? "opencode.exe" : "opencode"
|
||||
const platform = platformMap[os.platform()] ?? os.platform()
|
||||
const arch = archMap[os.arch()] ?? os.arch()
|
||||
const base = `opencode-${platform}-${arch}`
|
||||
const sourceBinary = platform === "windows" ? "opencode.exe" : "opencode"
|
||||
const targetBinary = path.join(__dirname, "bin", "opencode.exe")
|
||||
|
||||
try {
|
||||
// Use require.resolve to find the package
|
||||
const packageJsonPath = require.resolve(`${packageName}/package.json`)
|
||||
const packageDir = path.dirname(packageJsonPath)
|
||||
const binaryPath = path.join(packageDir, "bin", binaryName)
|
||||
function supportsAvx2() {
|
||||
if (arch !== "x64") return false
|
||||
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
throw new Error(`Binary not found at ${binaryPath}`)
|
||||
}
|
||||
|
||||
return { binaryPath, binaryName }
|
||||
} catch (error) {
|
||||
throw new Error(`Could not find package ${packageName}: ${error.message}`, { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
if (os.platform() === "win32") {
|
||||
// On Windows, the .exe is already included in the package and bin field points to it
|
||||
// No postinstall setup needed
|
||||
console.log("Windows detected: binary setup not needed (using packaged .exe)")
|
||||
return
|
||||
}
|
||||
|
||||
// On non-Windows platforms, just verify the binary package exists
|
||||
// Don't replace the wrapper script - it handles binary execution
|
||||
const { binaryPath } = findBinary()
|
||||
const target = path.join(__dirname, "bin", ".opencode")
|
||||
if (fs.existsSync(target)) fs.unlinkSync(target)
|
||||
if (platform === "linux") {
|
||||
try {
|
||||
fs.linkSync(binaryPath, target)
|
||||
return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8"))
|
||||
} catch {
|
||||
fs.copyFileSync(binaryPath, target)
|
||||
return false
|
||||
}
|
||||
fs.chmodSync(target, 0o755)
|
||||
} catch (error) {
|
||||
console.error("Failed to setup opencode binary:", error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (platform === "darwin") {
|
||||
try {
|
||||
const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], {
|
||||
encoding: "utf8",
|
||||
timeout: 1500,
|
||||
})
|
||||
if (result.status !== 0) return false
|
||||
return (result.stdout || "").trim() === "1"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === "windows") {
|
||||
const command =
|
||||
'(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)'
|
||||
|
||||
for (const executable of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) {
|
||||
try {
|
||||
const result = childProcess.spawnSync(executable, ["-NoProfile", "-NonInteractive", "-Command", command], {
|
||||
encoding: "utf8",
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
})
|
||||
if (result.status !== 0) continue
|
||||
const output = (result.stdout || "").trim().toLowerCase()
|
||||
if (output === "true" || output === "1") return true
|
||||
if (output === "false" || output === "0") return false
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function isMusl() {
|
||||
if (platform !== "linux") return false
|
||||
|
||||
try {
|
||||
if (fs.existsSync("/etc/alpine-release")) return true
|
||||
} catch {
|
||||
// Ignore filesystem probes that are blocked by the host.
|
||||
}
|
||||
|
||||
try {
|
||||
const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" })
|
||||
return `${result.stdout || ""}${result.stderr || ""}`.toLowerCase().includes("musl")
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function packageNames() {
|
||||
const baseline = arch === "x64" && !supportsAvx2()
|
||||
|
||||
if (platform === "linux") {
|
||||
if (isMusl()) {
|
||||
if (arch === "x64")
|
||||
return baseline
|
||||
? [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base]
|
||||
: [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`]
|
||||
return [`${base}-musl`, base]
|
||||
}
|
||||
|
||||
if (arch === "x64")
|
||||
return baseline
|
||||
? [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`]
|
||||
: [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`]
|
||||
return [base, `${base}-musl`]
|
||||
}
|
||||
|
||||
if (arch === "x64") return baseline ? [`${base}-baseline`, base] : [base, `${base}-baseline`]
|
||||
return [base]
|
||||
}
|
||||
|
||||
function resolveBinary(name) {
|
||||
const packageJsonPath = require.resolve(`${name}/package.json`)
|
||||
const binaryPath = path.join(path.dirname(packageJsonPath), "bin", sourceBinary)
|
||||
if (!fs.existsSync(binaryPath)) throw new Error(`Binary not found at ${binaryPath}`)
|
||||
return binaryPath
|
||||
}
|
||||
|
||||
function installPackage(name) {
|
||||
const version = packageJson.optionalDependencies?.[name]
|
||||
if (!version) return
|
||||
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-install-"))
|
||||
try {
|
||||
const result = childProcess.spawnSync(
|
||||
"npm",
|
||||
["install", "--ignore-scripts", "--no-save", "--loglevel=error", "--prefix", temp, `${name}@${version}`],
|
||||
{ stdio: "inherit", windowsHide: true },
|
||||
)
|
||||
if (result.status !== 0) return
|
||||
const packageDir = path.join(temp, "node_modules", name)
|
||||
copyBinary(path.join(packageDir, "bin", sourceBinary), targetBinary)
|
||||
return true
|
||||
} finally {
|
||||
fs.rmSync(temp, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function copyBinary(source, target) {
|
||||
if (!fs.existsSync(source)) throw new Error(`Binary not found at ${source}`)
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true })
|
||||
if (fs.existsSync(target)) fs.unlinkSync(target)
|
||||
try {
|
||||
fs.linkSync(source, target)
|
||||
} catch {
|
||||
fs.copyFileSync(source, target)
|
||||
}
|
||||
fs.chmodSync(target, 0o755)
|
||||
}
|
||||
|
||||
function verifyBinary() {
|
||||
const result = childProcess.spawnSync(targetBinary, ["--version"], {
|
||||
encoding: "utf8",
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
})
|
||||
return result.status === 0
|
||||
}
|
||||
|
||||
function main() {
|
||||
for (const name of packageNames()) {
|
||||
try {
|
||||
copyBinary(resolveBinary(name), targetBinary)
|
||||
if (verifyBinary()) return
|
||||
} catch {
|
||||
if (installPackage(name) && verifyBinary()) return
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`It seems your package manager failed to install the right opencode CLI package. Try manually installing ${packageNames()
|
||||
.map((name) => JSON.stringify(name))
|
||||
.join(" or ")}.`,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
void main()
|
||||
main()
|
||||
} catch (error) {
|
||||
console.error("Postinstall script error:", error.message)
|
||||
process.exit(0)
|
||||
console.error(error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -32,22 +32,39 @@ console.log("binaries", binaries)
|
||||
const version = Object.values(binaries)[0]
|
||||
|
||||
await $`mkdir -p ./dist/${pkg.name}`
|
||||
await $`cp -r ./bin ./dist/${pkg.name}/bin`
|
||||
await $`mkdir -p ./dist/${pkg.name}/bin`
|
||||
await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs`
|
||||
await Bun.file(`./dist/${pkg.name}/LICENSE`).write(await Bun.file("../../LICENSE").text())
|
||||
await Bun.file(`./dist/${pkg.name}/bin/${pkg.name}.exe`).write(
|
||||
[
|
||||
`echo "Error: ${pkg.name}-ai's postinstall script was not run." >&2`,
|
||||
'echo "" >&2',
|
||||
'echo "This occurs when using --ignore-scripts during installation, or when using a" >&2',
|
||||
'echo "package manager like pnpm that does not run postinstall scripts by default." >&2',
|
||||
'echo "" >&2',
|
||||
'echo "To fix this, run the postinstall script manually:" >&2',
|
||||
`echo " cd node_modules/${pkg.name}-ai && node postinstall.mjs" >&2`,
|
||||
'echo "" >&2',
|
||||
`echo "Or reinstall ${pkg.name}-ai without the --ignore-scripts flag." >&2`,
|
||||
"exit 1",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
await Bun.file(`./dist/${pkg.name}/package.json`).write(
|
||||
JSON.stringify(
|
||||
{
|
||||
name: pkg.name + "-ai",
|
||||
bin: {
|
||||
[pkg.name]: `./bin/${pkg.name}`,
|
||||
[pkg.name]: `./bin/${pkg.name}.exe`,
|
||||
},
|
||||
scripts: {
|
||||
postinstall: "bun ./postinstall.mjs || node ./postinstall.mjs",
|
||||
postinstall: "node ./postinstall.mjs",
|
||||
},
|
||||
version: version,
|
||||
license: pkg.license,
|
||||
os: ["darwin", "linux", "win32"],
|
||||
cpu: ["arm64", "x64"],
|
||||
optionalDependencies: binaries,
|
||||
},
|
||||
null,
|
||||
|
||||
@@ -70,11 +70,54 @@ Endpoint definitions declare which public errors can be emitted. Public
|
||||
HTTP error schemas carry their response status with `httpApiStatus` or the
|
||||
equivalent HttpApi schema annotation.
|
||||
|
||||
Effect's own HttpApi examples follow this pattern:
|
||||
|
||||
```ts
|
||||
export class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
|
||||
"Unauthorized",
|
||||
{ message: Schema.String },
|
||||
{ httpApiStatus: 401 },
|
||||
) {}
|
||||
|
||||
export class Authorization extends HttpApiMiddleware.Service<
|
||||
Authorization,
|
||||
{
|
||||
provides: CurrentUser
|
||||
}
|
||||
>()("app/Authorization", {
|
||||
security: { bearer: HttpApiSecurity.bearer },
|
||||
error: Unauthorized,
|
||||
}) {}
|
||||
```
|
||||
|
||||
Endpoint-level errors use the same idea:
|
||||
|
||||
```ts
|
||||
export class ConfigApiError extends Schema.ErrorClass<ConfigApiError>("ConfigApiError")(
|
||||
{
|
||||
name: Schema.Union(Schema.Literal("ConfigInvalidError"), Schema.Literal("ConfigJsonError")),
|
||||
data: Schema.Struct({ message: Schema.optional(Schema.String), path: Schema.String }),
|
||||
},
|
||||
{ httpApiStatus: 400 },
|
||||
) {}
|
||||
|
||||
HttpApiEndpoint.get("get", "/config", {
|
||||
success: Config.Info,
|
||||
error: ConfigApiError,
|
||||
})
|
||||
```
|
||||
|
||||
The service error and HTTP error may be the same class only when the wire
|
||||
shape is intentionally public. Use separate HTTP error schemas when the
|
||||
service error contains internals, low-level causes, retry hints, or data
|
||||
that should not be exposed to API clients.
|
||||
|
||||
Do not map every domain error into one universal HTTP error class. Prefer a
|
||||
small public error vocabulary by route group: shared shapes like
|
||||
`ApiNotFoundError`, route-specific shapes like `ConfigApiError`, and built-in
|
||||
empty `HttpApiError.*` only when an empty/no-content body is the intended SDK
|
||||
contract.
|
||||
|
||||
## Mapping Guidance
|
||||
|
||||
- Keep one-off translations inline in the handler.
|
||||
@@ -86,6 +129,35 @@ that should not be exposed to API clients.
|
||||
breaking API change.
|
||||
- Use built-in `HttpApiError.*` only when its generated body and SDK
|
||||
surface are intentionally the public contract.
|
||||
- Prefer `Schema.ErrorClass` for public HTTP error bodies whose wire shape is
|
||||
not the same as the internal domain error shape.
|
||||
- Prefer `Schema.TaggedErrorClass` for service/domain errors and middleware
|
||||
errors that are naturally tagged by `_tag`.
|
||||
- If preserving a legacy `{ name, data }` body, model that shape explicitly in
|
||||
the public API error schema instead of relying on `NamedError.toObject()` in
|
||||
generic middleware.
|
||||
|
||||
## User-Facing Rendering
|
||||
|
||||
HTTP serialization and user rendering are separate boundaries. The server
|
||||
should send structured public errors; CLI and TUI code should format those
|
||||
structures through one shared formatter.
|
||||
|
||||
For SDK calls using `{ throwOnError: true }`, the generated client may wrap the
|
||||
decoded response body in an `Error`. The original body should remain available
|
||||
under `error.cause.body`; `FormatError` is the right place to unwrap and render
|
||||
that body. TUI aggregation helpers should call `FormatError` first, then fall
|
||||
back to generic `Error.message` / string rendering.
|
||||
|
||||
When several parallel startup requests fail from the same underlying issue,
|
||||
group identical rendered messages and list the affected request names once.
|
||||
For example:
|
||||
|
||||
```text
|
||||
Configuration is invalid at /path/to/opencode.json
|
||||
↳ Expected object, got "not-object" provider.bad.options
|
||||
Affected startup requests: config.providers, provider.list, app.agents, config.get
|
||||
```
|
||||
|
||||
## Middleware Guidance
|
||||
|
||||
@@ -99,6 +171,15 @@ middleware should shrink. It should not gain new name checks.
|
||||
Unknown `500` responses should log full details server-side with
|
||||
`Cause.pretty(cause)` and return a safe public body.
|
||||
|
||||
The config startup regression in #27056 is the failure mode this rule is meant
|
||||
to avoid: a user-authored invalid `opencode.json` crossed the HttpApi boundary
|
||||
as a defect, so middleware replaced a useful `ConfigInvalidError` with a safe
|
||||
generic `UnknownError`. The compatibility fix is to preserve config parse and
|
||||
validation errors as client-visible `400`s. The target architecture is better:
|
||||
config loading should fail on the typed error channel, config HTTP handlers
|
||||
should map those errors to declared `ConfigApiError` responses, and the generic
|
||||
middleware should never see them.
|
||||
|
||||
## Migration Order
|
||||
|
||||
Prefer small vertical slices:
|
||||
@@ -113,6 +194,9 @@ Prefer small vertical slices:
|
||||
Good early domains are storage not-found, worktree errors, and provider
|
||||
auth validation errors because they currently drive HTTP behavior.
|
||||
|
||||
Config parse and validation errors are also a good early slice because they
|
||||
are startup-blocking and must be rendered clearly in both CLI and TUI flows.
|
||||
|
||||
## Checklist For A PR
|
||||
|
||||
- [ ] Expected failures are typed errors, not defects.
|
||||
|
||||
@@ -6,7 +6,6 @@ Current status on this branch:
|
||||
|
||||
- `src/` has 5 `makeRuntime(...)` call sites total.
|
||||
- 2 are intentionally excluded from this checklist: `src/bus/index.ts` and `src/effect/cross-spawn-spawner.ts`.
|
||||
- 1 is tracked primarily by the instance-context migration rather than facade removal: `src/project/instance.ts`.
|
||||
- That leaves 2 live runtime-backed service facades still worth tracking here: `src/npm/index.ts` and `src/cli/cmd/tui/config/tui.ts`.
|
||||
|
||||
Recent progress:
|
||||
@@ -18,7 +17,6 @@ Recent progress:
|
||||
|
||||
- `src/cli/cmd/tui/config/tui.ts` still exports `makeRuntime(...)` plus async facade helpers for `get()` and `waitForDependencies()`.
|
||||
- `src/npm/index.ts` still exports `makeRuntime(...)` plus async facade helpers for `install()`, `add()`, `outdated()`, and `which()`.
|
||||
- `src/project/instance.ts` still uses a dedicated runtime for project boot, but that file is really part of the broader legacy instance-context transition tracked in `instance-context.md`.
|
||||
|
||||
## Completed Batches
|
||||
|
||||
@@ -192,7 +190,6 @@ Most of the original facade-removal backlog is already done. The practical remai
|
||||
|
||||
1. remove the `Npm` runtime-backed facade from `src/npm/index.ts`
|
||||
2. remove the `TuiConfig` runtime-backed facade from `src/cli/cmd/tui/config/tui.ts`
|
||||
3. keep `src/project/instance.ts` in the separate instance-context migration, not this checklist
|
||||
|
||||
## Checklist
|
||||
|
||||
|
||||
@@ -197,13 +197,9 @@ For background loops, use `Effect.repeat` or `Effect.schedule` with
|
||||
|
||||
[`EffectBridge`](../../src/effect/bridge.ts) is the sanctioned helper for
|
||||
Promise/callback interop that needs to preserve instance/workspace context.
|
||||
Keep it, but reduce its dependency on legacy `Instance.current` /
|
||||
`Instance.restore` over time.
|
||||
|
||||
`Instance.bind` / `Instance.restore` are transitional legacy tools. Use
|
||||
them only for native callbacks that still require legacy ALS context. Do
|
||||
not use them for `setTimeout`, `Promise.then`, `EventEmitter.on`, or
|
||||
Effect fibers.
|
||||
It preserves explicit `InstanceRef` / `WorkspaceRef` context for effects run
|
||||
through the bridge. Plain JS callbacks that need instance data should receive
|
||||
that data explicitly.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -1,309 +1,13 @@
|
||||
# Instance context migration
|
||||
# Instance Context
|
||||
|
||||
Practical plan for retiring the promise-backed / ALS-backed `Instance` helper in `src/project/instance.ts` and moving instance selection fully into Effect-provided scope.
|
||||
Instance selection is now Effect-provided context.
|
||||
|
||||
## Goal
|
||||
Use these APIs:
|
||||
|
||||
End state:
|
||||
- `InstanceRef` for the current project context.
|
||||
- `WorkspaceRef` for the current workspace id.
|
||||
- `InstanceState.context` / `InstanceState.directory` inside Effect services that require an instance.
|
||||
- `InstanceStore` at entry boundaries that need to load, reload, or dispose project contexts.
|
||||
- `EffectBridge` for native, plugin, or plain JavaScript callback boundaries that need to re-enter Effect with captured refs.
|
||||
|
||||
- request, CLI, TUI, and tool entrypoints shift into an instance through Effect, not `Instance.provide(...)`
|
||||
- Effect code reads the current instance from `InstanceRef` or its eventual replacement, not from ALS-backed sync getters
|
||||
- per-directory boot, caching, and disposal are scoped Effect resources, not a module-level `Map<string, Promise<InstanceContext>>`
|
||||
- ALS remains only as a temporary bridge for native callback APIs that fire outside the Effect fiber tree
|
||||
|
||||
## Current split
|
||||
|
||||
Today `src/project/instance.ts` still owns two separate concerns:
|
||||
|
||||
- ambient current-instance context through `LocalContext` / `AsyncLocalStorage`
|
||||
- per-directory boot and deduplication through `cache: Map<string, Promise<InstanceContext>>`
|
||||
|
||||
At the same time, the Effect side already exists:
|
||||
|
||||
- `src/effect/instance-ref.ts` provides `InstanceRef` and `WorkspaceRef`
|
||||
- `src/effect/run-service.ts` already attaches those refs when a runtime starts inside an active instance ALS context
|
||||
- `src/effect/instance-state.ts` already prefers `InstanceRef` and only falls back to ALS when needed
|
||||
|
||||
That means the migration is not "invent instance context in Effect". The migration is "stop relying on the legacy helper as the primary source of truth".
|
||||
|
||||
## End state shape
|
||||
|
||||
Near-term target shape:
|
||||
|
||||
```ts
|
||||
InstanceScope.with({ directory, workspaceID }, effect)
|
||||
```
|
||||
|
||||
Responsibilities of `InstanceScope.with(...)`:
|
||||
|
||||
- resolve `directory`, `project`, and `worktree`
|
||||
- acquire or reuse the scoped per-directory instance environment
|
||||
- provide `InstanceRef` and `WorkspaceRef`
|
||||
- run the caller's Effect inside that environment
|
||||
|
||||
Code inside the boundary should then do one of these:
|
||||
|
||||
```ts
|
||||
const ctx = yield * InstanceState.context
|
||||
const dir = yield * InstanceState.directory
|
||||
```
|
||||
|
||||
Long-term, once `InstanceState` itself is replaced by keyed layers / `LayerMap`, those reads can move to an `InstanceContext` service without changing the outer migration order.
|
||||
|
||||
## Migration phases
|
||||
|
||||
### Phase 1: stop expanding the legacy surface
|
||||
|
||||
Rules for all new code:
|
||||
|
||||
- do not add new `Instance.directory`, `Instance.worktree`, `Instance.project`, or `Instance.current` reads inside Effect code
|
||||
- do not add new `Instance.provide(...)` boundaries unless there is no Effect-native seam yet
|
||||
- use `InstanceState.context`, `InstanceState.directory`, or an explicit `ctx` parameter inside Effect code
|
||||
|
||||
Success condition:
|
||||
|
||||
- the file inventory below only shrinks from here
|
||||
|
||||
### Phase 2: remove direct sync getter reads from Effect services
|
||||
|
||||
Convert Effect services first, before replacing the top-level boundary. These modules already run inside Effect and mostly need `yield* InstanceState.context` or a yielded `ctx` instead of ambient sync access.
|
||||
|
||||
Primary batch, highest payoff:
|
||||
|
||||
- `src/file/index.ts`
|
||||
- `src/lsp/server.ts`
|
||||
- `src/worktree/index.ts`
|
||||
- `src/file/watcher.ts`
|
||||
- `src/format/formatter.ts`
|
||||
- `src/session/index.ts`
|
||||
- `src/project/vcs.ts`
|
||||
|
||||
Mechanical replacement rule:
|
||||
|
||||
- `Instance.directory` -> `ctx.directory` or `yield* InstanceState.directory`
|
||||
- `Instance.worktree` -> `ctx.worktree`
|
||||
- `Instance.project` -> `ctx.project`
|
||||
|
||||
Do not thread strings manually through every public method if the service already has access to Effect context.
|
||||
|
||||
### Phase 3: convert entry boundaries to provide instance refs directly
|
||||
|
||||
After the service bodies stop assuming ALS, move the top-level boundaries to shift into Effect explicitly.
|
||||
|
||||
Main boundaries:
|
||||
|
||||
- HTTP server middleware and experimental `HttpApi` entrypoints
|
||||
- CLI commands
|
||||
- TUI worker / attach / thread entrypoints
|
||||
- tool execution entrypoints
|
||||
|
||||
These boundaries should become Effect-native wrappers that:
|
||||
|
||||
- decode directory / workspace inputs
|
||||
- resolve the instance context once
|
||||
- provide `InstanceRef` and `WorkspaceRef`
|
||||
- run the requested Effect
|
||||
|
||||
At that point `Instance.provide(...)` becomes a legacy adapter instead of the normal code path.
|
||||
|
||||
### Phase 4: replace promise boot cache with scoped instance runtime
|
||||
|
||||
Once boundaries and services both rely on Effect context, replace the module-level promise cache in `src/project/instance.ts`.
|
||||
|
||||
Target replacement:
|
||||
|
||||
- keyed scoped runtime or keyed layer acquisition for each directory
|
||||
- reuse via `ScopedCache`, `LayerMap`, or another keyed Effect resource manager
|
||||
- cleanup performed by scope finalizers instead of `disposeAll()` iterating a Promise map
|
||||
|
||||
This phase should absorb the current responsibilities of:
|
||||
|
||||
- `cache` in `src/project/instance.ts`
|
||||
- `boot(...)`
|
||||
- most of `disposeInstance(...)`
|
||||
- manual `reload(...)` / `disposeAll()` fan-out logic
|
||||
|
||||
### Phase 5: shrink ALS to callback bridges only
|
||||
|
||||
Keep ALS only where a library invokes callbacks outside the Effect fiber tree and we still need to call code that reads instance context synchronously.
|
||||
|
||||
Known bridge cases today:
|
||||
|
||||
- `src/file/watcher.ts`
|
||||
- `src/session/llm.ts`
|
||||
- some LSP and plugin callback paths
|
||||
|
||||
If those libraries become fully wrapped in Effect services, the remaining `Instance.bind(...)` uses can disappear too.
|
||||
|
||||
### Phase 6: delete the legacy sync API
|
||||
|
||||
Only after earlier phases land:
|
||||
|
||||
- remove broad use of `Instance.current`, `Instance.directory`, `Instance.worktree`, `Instance.project`
|
||||
- reduce `src/project/instance.ts` to a thin compatibility shim or delete it entirely
|
||||
- remove the ALS fallback from `InstanceState.context`
|
||||
|
||||
## Inventory of direct legacy usage
|
||||
|
||||
Direct legacy usage means any source file that still calls one of:
|
||||
|
||||
- `Instance.current`
|
||||
- `Instance.directory`
|
||||
- `Instance.worktree`
|
||||
- `Instance.project`
|
||||
- `Instance.provide(...)`
|
||||
- `Instance.bind(...)`
|
||||
- `Instance.restore(...)`
|
||||
- `Instance.reload(...)`
|
||||
- `Instance.dispose()` / `Instance.disposeAll()`
|
||||
|
||||
Current total: `56` files in `packages/opencode/src`.
|
||||
|
||||
### Core bridge and plumbing
|
||||
|
||||
These files define or adapt the current bridge. They should change last, after callers have moved.
|
||||
|
||||
- `src/project/instance.ts`
|
||||
- `src/effect/run-service.ts`
|
||||
- `src/effect/instance-state.ts`
|
||||
- `src/project/bootstrap.ts`
|
||||
- `src/config/config.ts`
|
||||
|
||||
Migration rule:
|
||||
|
||||
- keep these as compatibility glue until the outer boundaries and inner services stop depending on ALS
|
||||
|
||||
### HTTP and server boundaries
|
||||
|
||||
These are the current request-entry seams that still create or consume instance context through the legacy helper.
|
||||
|
||||
- `src/server/routes/instance/middleware.ts`
|
||||
- `src/server/routes/instance/index.ts`
|
||||
- `src/server/routes/instance/project.ts`
|
||||
- `src/server/routes/control/workspace.ts`
|
||||
- `src/server/routes/instance/file.ts`
|
||||
- `src/server/routes/instance/experimental.ts`
|
||||
- `src/server/routes/global.ts`
|
||||
|
||||
Migration rule:
|
||||
|
||||
- move these to explicit Effect entrypoints that provide `InstanceRef` / `WorkspaceRef`
|
||||
- do not move these first; first reduce the number of downstream handlers and services that still expect ambient ALS
|
||||
|
||||
### CLI and TUI boundaries
|
||||
|
||||
These commands still enter an instance through `Instance.provide(...)` or read sync getters directly.
|
||||
|
||||
- `src/cli/bootstrap.ts`
|
||||
- `src/cli/cmd/agent.ts`
|
||||
- `src/cli/cmd/debug/agent.ts`
|
||||
- `src/cli/cmd/debug/ripgrep.ts`
|
||||
- `src/cli/cmd/github.ts`
|
||||
- `src/cli/cmd/import.ts`
|
||||
- `src/cli/cmd/mcp.ts`
|
||||
- `src/cli/cmd/models.ts`
|
||||
- `src/cli/cmd/plug.ts`
|
||||
- `src/cli/cmd/pr.ts`
|
||||
- `src/cli/cmd/providers.ts`
|
||||
- `src/cli/cmd/stats.ts`
|
||||
- `src/cli/cmd/tui/attach.ts`
|
||||
- `src/cli/cmd/tui/plugin/runtime.ts`
|
||||
- `src/cli/cmd/tui/thread.ts`
|
||||
- `src/cli/cmd/tui/worker.ts`
|
||||
|
||||
Migration rule:
|
||||
|
||||
- converge these on one shared `withInstance(...)` Effect entry helper instead of open-coded `Instance.provide(...)`
|
||||
- after that helper is proven, inline the legacy implementation behind an Effect-native scope provider
|
||||
|
||||
### Tool boundary code
|
||||
|
||||
These tools mostly use direct getters for path resolution and repo-relative display logic.
|
||||
|
||||
- `src/tool/apply_patch.ts`
|
||||
- `src/tool/bash.ts`
|
||||
- `src/tool/edit.ts`
|
||||
- `src/tool/lsp.ts`
|
||||
- `src/tool/plan.ts`
|
||||
- `src/tool/read.ts`
|
||||
- `src/tool/write.ts`
|
||||
|
||||
Migration rule:
|
||||
|
||||
- expose the current instance as an explicit Effect dependency for tool execution
|
||||
- keep path logic local; avoid introducing another global singleton for tool state
|
||||
|
||||
### Effect services still reading ambient instance state
|
||||
|
||||
These modules are already the best near-term migration targets because they are in Effect code but still read sync getters from the legacy helper.
|
||||
|
||||
- `src/agent/agent.ts`
|
||||
- `src/cli/cmd/tui/config/tui-migrate.ts`
|
||||
- `src/file/index.ts`
|
||||
- `src/file/watcher.ts`
|
||||
- `src/format/formatter.ts`
|
||||
- `src/lsp/client.ts`
|
||||
- `src/lsp/index.ts`
|
||||
- `src/lsp/server.ts`
|
||||
- `src/mcp/index.ts`
|
||||
- `src/project/vcs.ts`
|
||||
- `src/provider/provider.ts`
|
||||
- `src/pty/index.ts`
|
||||
- `src/session/session.ts`
|
||||
- `src/session/instruction.ts`
|
||||
- `src/session/llm.ts`
|
||||
- `src/session/system.ts`
|
||||
- `src/sync/index.ts`
|
||||
- `src/worktree/index.ts`
|
||||
|
||||
Migration rule:
|
||||
|
||||
- replace direct getter reads with `yield* InstanceState.context` or a yielded `ctx`
|
||||
- isolate `Instance.bind(...)` callers and convert only the truly callback-driven edges to bridge mode
|
||||
|
||||
### Highest-churn hotspots
|
||||
|
||||
Current highest direct-usage counts by file:
|
||||
|
||||
- `src/file/index.ts` - `18`
|
||||
- `src/lsp/server.ts` - `14`
|
||||
- `src/worktree/index.ts` - `12`
|
||||
- `src/file/watcher.ts` - `9`
|
||||
- `src/cli/cmd/mcp.ts` - `8`
|
||||
- `src/format/formatter.ts` - `8`
|
||||
- `src/tool/apply_patch.ts` - `8`
|
||||
- `src/cli/cmd/github.ts` - `7`
|
||||
|
||||
These files should drive the first measurable burn-down.
|
||||
|
||||
## Recommended implementation order
|
||||
|
||||
1. Migrate direct getter reads inside Effect services, starting with `file`, `lsp`, `worktree`, `format`, and `session`.
|
||||
2. Add one shared Effect-native boundary helper for CLI / tool / HTTP entrypoints so we stop open-coding `Instance.provide(...)`.
|
||||
3. Move experimental `HttpApi` entrypoints to that helper so the new server stack proves the pattern.
|
||||
4. Convert remaining CLI and tool boundaries.
|
||||
5. Replace the promise cache with a keyed scoped runtime or keyed layer map.
|
||||
6. Delete ALS fallback paths once only callback bridges still depend on them.
|
||||
|
||||
## Definition of done
|
||||
|
||||
This migration is done when all of the following are true:
|
||||
|
||||
- new requests and commands enter an instance by providing Effect context, not ALS
|
||||
- Effect services no longer read `Instance.directory`, `Instance.worktree`, `Instance.project`, or `Instance.current`
|
||||
- `Instance.provide(...)` is gone from normal request / CLI / tool execution
|
||||
- per-directory boot and disposal are handled by scoped Effect resources
|
||||
- `Instance.bind(...)` is either gone or confined to a tiny set of native callback adapters
|
||||
|
||||
## Tracker and worktree
|
||||
|
||||
Active tracker items:
|
||||
|
||||
- `lh7l73` - overall `HttpApi` migration
|
||||
- `yobwlk` - remove direct `Instance.*` reads inside Effect services
|
||||
- `7irl1e` - replace `InstanceState` / legacy instance caching with keyed Effect layers
|
||||
|
||||
Dedicated worktree for this transition:
|
||||
|
||||
- path: `/Users/kit/code/open-source/opencode-worktrees/instance-effect-shift`
|
||||
- branch: `kit/instance-effect-shift`
|
||||
Do not add new ambient instance globals. Promise and callback boundaries should either stay in Effect, use `EffectBridge`, or pass the required context explicitly.
|
||||
|
||||
@@ -24,10 +24,6 @@ Small follow-ups that do not fit neatly into the main facade, route, tool, or sc
|
||||
- [ ] `cli/cmd/tui/config/tui.ts` - switch TUI config loading from async `ConfigPaths.*` wrappers to the `ConfigPaths.Service` once that service exists.
|
||||
- [ ] `cli/cmd/tui/config/tui-migrate.ts` - decide whether to leave this as a plain async module using wrapper functions or effectify it fully after `ConfigPaths.Service` lands.
|
||||
|
||||
## Instance cleanup
|
||||
|
||||
- [ ] `project/instance.ts` - keep shrinking the legacy ALS / Promise cache after the remaining `Instance.*` callers move over.
|
||||
|
||||
## Notes
|
||||
|
||||
- Prefer small, semantics-preserving config migrations. Config precedence, legacy key migration, and plugin origin tracking are easy to break accidentally.
|
||||
|
||||
@@ -64,13 +64,11 @@ P6 OA
|
||||
explicit and testable instead of mutable module state.
|
||||
Shrinks: [`global.ts`](../../../core/src/global.ts) import-time side
|
||||
effects, mutable `Global.Path` overrides, and its `Flag` dependency.
|
||||
- `INST` Instance shim — remove ambient `Instance` usage and old ALS
|
||||
access patterns.
|
||||
Shrinks: [`src/project/instance.ts`](../../src/project/instance.ts).
|
||||
- `INST` Instance context — keep project context explicit through Effect refs
|
||||
and bridge boundaries.
|
||||
- `BRIDGE` Promise/callback interop — keep bridge helpers, but reduce
|
||||
legacy ALS coupling.
|
||||
Shrinks: [`src/effect/bridge.ts`](../../src/effect/bridge.ts)
|
||||
dependency on [`project/instance.ts`](../../src/project/instance.ts).
|
||||
Shrinks: ad hoc Promise/callback re-entry code.
|
||||
- `PROC` AppProcess migration — prefer `AppProcess.Service` over raw
|
||||
process wrappers.
|
||||
Shrinks: direct spawn callsites and legacy process helpers.
|
||||
@@ -221,74 +219,13 @@ Next PR candidates:
|
||||
|
||||
## P4: Instance And Bridge
|
||||
|
||||
[`project/instance.ts`](../../src/project/instance.ts) is the deletion
|
||||
target. [`effect/bridge.ts`](../../src/effect/bridge.ts) is not a near-term
|
||||
deletion target; Promise/callback interop will continue to exist.
|
||||
Instance context migration is complete for the legacy sync shim. Promise and callback interop continues through [`effect/bridge.ts`](../../src/effect/bridge.ts).
|
||||
|
||||
Goal:
|
||||
Current rules:
|
||||
|
||||
- Keep a sanctioned bridge for Promise/callback boundaries.
|
||||
- Reduce bridge dependence on legacy `Instance.restore` / `Instance.current`.
|
||||
- Move callers toward `InstanceRef`, `WorkspaceRef`, `InstanceState`, or
|
||||
explicit context where practical.
|
||||
- Delete `project/instance.ts` only after ambient Instance coupling is gone.
|
||||
|
||||
Important distinction:
|
||||
|
||||
- `InstanceState.context`, `InstanceState.directory`, and
|
||||
`InstanceState.workspaceID` are acceptable inside normal Effect service
|
||||
code when `InstanceRef` / `WorkspaceRef` are provided by the runtime.
|
||||
- The deletion blockers are the fallback and callback paths that rely on
|
||||
ambient ALS: direct `Instance.*` reads, `InstanceState.bind(...)`,
|
||||
`AppRuntime.runPromise(...)` re-entry from plain JS, and bridge restore
|
||||
code that installs legacy ALS before invoking callbacks.
|
||||
|
||||
Current bottom-up inventory from `dev`:
|
||||
|
||||
- Direct `Instance.*` value readers:
|
||||
[`tool/repo_overview.ts`](../../src/tool/repo_overview.ts),
|
||||
[`control-plane/adapters/worktree.ts`](../../src/control-plane/adapters/worktree.ts),
|
||||
[`cli/bootstrap.ts`](../../src/cli/bootstrap.ts).
|
||||
- `InstanceState.bind(...)` callback boundaries:
|
||||
[`file/watcher.ts`](../../src/file/watcher.ts) native watcher callback,
|
||||
[`storage/db.ts`](../../src/storage/db.ts) transaction/effect callbacks,
|
||||
[`session/llm.ts`](../../src/session/llm.ts) workflow approval callback.
|
||||
- `AppRuntime.runPromise(...)` / re-entry from plain JS:
|
||||
[`project/with-instance.ts`](../../src/project/with-instance.ts),
|
||||
[`project/instance-runtime.ts`](../../src/project/instance-runtime.ts),
|
||||
[`control-plane/adapters/worktree.ts`](../../src/control-plane/adapters/worktree.ts),
|
||||
[`cli/effect-cmd.ts`](../../src/cli/effect-cmd.ts), plus global/non-instance
|
||||
callsites such as CLI upgrade and ACP agent defaults.
|
||||
- Intentional bridge users to classify, not delete blindly:
|
||||
workspace adapters in [`control-plane/workspace.ts`](../../src/control-plane/workspace.ts),
|
||||
MCP, command execution, plugins, pty lifecycle, bus scope cleanup, task
|
||||
cancellation, and HTTP lifecycle reload/dispose paths.
|
||||
- Core fallback layer to shrink last:
|
||||
[`effect/run-service.ts`](../../src/effect/run-service.ts),
|
||||
[`effect/bridge.ts`](../../src/effect/bridge.ts), and
|
||||
[`effect/instance-state.ts`](../../src/effect/instance-state.ts).
|
||||
|
||||
Recommended PR order:
|
||||
|
||||
- [ ] `INST-1` Remove direct `Instance.*` value readers. Start with
|
||||
`repo_overview`, `worktree` adapter, and `cli/bootstrap`; pass context
|
||||
explicitly or obtain it from an Effect boundary.
|
||||
- [ ] `INST-2` Move type-only `InstanceContext` imports from
|
||||
[`project/instance.ts`](../../src/project/instance.ts) to
|
||||
[`project/instance-context.ts`](../../src/project/instance-context.ts).
|
||||
- [ ] `INST-3` Audit each `InstanceState.bind(...)` callback from the inside
|
||||
out: list what the callback calls (`Bus.publish`, database effects,
|
||||
permission/session services), then replace ambient capture with explicit
|
||||
`InstanceRef` / `WorkspaceRef` provision or an `EffectBridge` call.
|
||||
- [ ] `INST-4` Classify `AppRuntime.runPromise(...)` callsites as global,
|
||||
instance-scoped with explicit refs, or bridge-required. Eliminate the
|
||||
instance-scoped callsites that rely on `run-service.attach()` falling
|
||||
back to `Instance.current`.
|
||||
- [ ] `INST-5` After consumers are explicit, remove `Instance.current` fallback
|
||||
from `InstanceState.context` and `run-service.attach()`.
|
||||
- [ ] `INST-6` Move any remaining `restore` / `bind` compatibility helpers to
|
||||
the boundary that still needs them, then delete
|
||||
[`project/instance.ts`](../../src/project/instance.ts).
|
||||
- Effect services read instance data from `InstanceRef`, `WorkspaceRef`, `InstanceState`, or explicit arguments.
|
||||
- Plain JavaScript callback boundaries use `EffectBridge` or explicit context arguments.
|
||||
- Runtime entrypoints must provide refs explicitly when they are instance-scoped.
|
||||
|
||||
## Lower Priority Tracks
|
||||
|
||||
|
||||
@@ -39,10 +39,9 @@ import { Filesystem } from "@/util/filesystem"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { ACPSessionManager } from "./session"
|
||||
import type { ACPConfig } from "./types"
|
||||
import { ACPRuntime } from "./runtime"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { Agent as AgentModule } from "../agent/agent"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Installation } from "@/installation"
|
||||
import { MessageV2 } from "@/session/message-v2"
|
||||
import { Config } from "@/config/config"
|
||||
@@ -1094,7 +1093,7 @@ export class Agent implements ACPAgent {
|
||||
|
||||
const currentModeId = await (async () => {
|
||||
if (!availableModes.length) return undefined
|
||||
const defaultAgent = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo()))
|
||||
const defaultAgent = await ACPRuntime.defaultAgentInfo(directory)
|
||||
const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgent.name)?.id ?? availableModes[0].id
|
||||
this.sessionManager.setMode(sessionId, resolvedModeId)
|
||||
return resolvedModeId
|
||||
@@ -1328,8 +1327,7 @@ export class Agent implements ACPAgent {
|
||||
if (!current) {
|
||||
this.sessionManager.setModel(session.id, model)
|
||||
}
|
||||
const agent =
|
||||
session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo()))).name
|
||||
const agent = session.modeId ?? (await ACPRuntime.defaultAgentInfo(directory)).name
|
||||
|
||||
const parts: Array<
|
||||
| { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { AppRuntime, type AppServices } from "@/effect/app-runtime"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { Effect } from "effect"
|
||||
|
||||
// Global ACP Effect re-entry: no project InstanceRef is provided.
|
||||
export const runGlobal = AppRuntime.runPromise
|
||||
|
||||
// Directory-scoped ACP Effect re-entry: load the project instance and provide InstanceRef.
|
||||
export async function runDirectory<A, E>(input: { directory: string; effect: Effect.Effect<A, E, AppServices> }) {
|
||||
const ctx = await InstanceRuntime.load({ directory: input.directory })
|
||||
return AppRuntime.runPromise(input.effect.pipe(Effect.provideService(InstanceRef, ctx)))
|
||||
}
|
||||
|
||||
export const defaultAgentInfo = (directory: string) =>
|
||||
runDirectory({
|
||||
directory,
|
||||
effect: Agent.Service.use((svc) => svc.defaultInfo()),
|
||||
})
|
||||
|
||||
export * as ACPRuntime from "./runtime"
|
||||
@@ -1,17 +1,11 @@
|
||||
import { Instance } from "../project/instance"
|
||||
import { InstanceRuntime } from "../project/instance-runtime"
|
||||
import { WithInstance } from "../project/with-instance"
|
||||
import { context } from "../project/instance-context"
|
||||
|
||||
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
|
||||
return WithInstance.provide({
|
||||
directory,
|
||||
fn: async () => {
|
||||
try {
|
||||
const result = await cb()
|
||||
return result
|
||||
} finally {
|
||||
await InstanceRuntime.disposeInstance(Instance.current)
|
||||
}
|
||||
},
|
||||
})
|
||||
const ctx = await InstanceRuntime.load({ directory })
|
||||
try {
|
||||
return await context.provide(ctx, cb)
|
||||
} finally {
|
||||
await InstanceRuntime.disposeInstance(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ const AgentCreateCommand = effectCmd({
|
||||
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
|
||||
const ctx = maybeCtx
|
||||
const agentSvc = yield* Agent.Service
|
||||
const runLocalEffect = <A, E>(effect: Effect.Effect<A, E>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provideService(InstanceRef, ctx)))
|
||||
yield* Effect.promise(async () => {
|
||||
const cliPath = args.path
|
||||
const cliDescription = args.description
|
||||
@@ -127,7 +129,7 @@ const AgentCreateCommand = effectCmd({
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Generating agent configuration...")
|
||||
const model = args.model ? Provider.parseModel(args.model) : undefined
|
||||
const generated = await Effect.runPromise(agentSvc.generate({ description, model })).catch((error) => {
|
||||
const generated = await runLocalEffect(agentSvc.generate({ description, model })).catch((error) => {
|
||||
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
|
||||
if (isFullyNonInteractive) process.exit(1)
|
||||
throw new UI.CancelledError()
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Permission } from "../../../permission"
|
||||
import { iife } from "../../../util/iife"
|
||||
import { effectCmd, fail } from "../../effect-cmd"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import type { InstanceContext } from "@/project/instance"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
|
||||
export const AgentCommand = effectCmd({
|
||||
command: "agent <name>",
|
||||
|
||||
@@ -435,6 +435,9 @@ export const GithubRunCommand = effectCmd({
|
||||
const sessionSvc = yield* Session.Service
|
||||
const sessionShare = yield* SessionShare.Service
|
||||
const sessionPrompt = yield* SessionPrompt.Service
|
||||
const busSvc = yield* Bus.Service
|
||||
const runLocalEffect = <A, E>(effect: Effect.Effect<A, E>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provideService(InstanceRef, ctx)))
|
||||
yield* Effect.promise(async () => {
|
||||
const isMock = args.token || args.event
|
||||
|
||||
@@ -548,7 +551,7 @@ export const GithubRunCommand = effectCmd({
|
||||
|
||||
// Setup opencode session
|
||||
const repoData = await fetchRepo()
|
||||
session = await Effect.runPromise(
|
||||
session = await runLocalEffect(
|
||||
sessionSvc.create({
|
||||
permission: [
|
||||
{
|
||||
@@ -559,11 +562,11 @@ export const GithubRunCommand = effectCmd({
|
||||
],
|
||||
}),
|
||||
)
|
||||
subscribeSessionEvents()
|
||||
await subscribeSessionEvents()
|
||||
shareId = await (async () => {
|
||||
if (share === false) return
|
||||
if (!share && repoData.data.private) return
|
||||
await Effect.runPromise(sessionShare.share(session.id))
|
||||
await runLocalEffect(sessionShare.share(session.id))
|
||||
return session.id.slice(-8)
|
||||
})()
|
||||
console.log("opencode session", session.id)
|
||||
@@ -870,7 +873,7 @@ export const GithubRunCommand = effectCmd({
|
||||
return { userPrompt: prompt, promptFiles: imgData }
|
||||
}
|
||||
|
||||
function subscribeSessionEvents() {
|
||||
async function subscribeSessionEvents() {
|
||||
const TOOL: Record<string, [string, string]> = {
|
||||
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
||||
bash: ["Shell", UI.Style.TEXT_DANGER_BOLD],
|
||||
@@ -893,33 +896,35 @@ export const GithubRunCommand = effectCmd({
|
||||
}
|
||||
|
||||
let text = ""
|
||||
Bus.subscribe(MessageV2.Event.PartUpdated, (evt) => {
|
||||
if (evt.properties.part.sessionID !== session.id) return
|
||||
//if (evt.properties.part.messageID === messageID) return
|
||||
const part = evt.properties.part
|
||||
await runLocalEffect(
|
||||
busSvc.subscribeCallback(MessageV2.Event.PartUpdated, (evt) => {
|
||||
if (evt.properties.part.sessionID !== session.id) return
|
||||
//if (evt.properties.part.messageID === messageID) return
|
||||
const part = evt.properties.part
|
||||
|
||||
if (part.type === "tool" && part.state.status === "completed") {
|
||||
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
|
||||
const title =
|
||||
part.state.title || Object.keys(part.state.input).length > 0
|
||||
? JSON.stringify(part.state.input)
|
||||
: "Unknown"
|
||||
console.log()
|
||||
printEvent(color, tool, title)
|
||||
}
|
||||
|
||||
if (part.type === "text") {
|
||||
text = part.text
|
||||
|
||||
if (part.time?.end) {
|
||||
UI.empty()
|
||||
UI.println(UI.markdown(text))
|
||||
UI.empty()
|
||||
text = ""
|
||||
return
|
||||
if (part.type === "tool" && part.state.status === "completed") {
|
||||
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
|
||||
const title =
|
||||
part.state.title || Object.keys(part.state.input).length > 0
|
||||
? JSON.stringify(part.state.input)
|
||||
: "Unknown"
|
||||
console.log()
|
||||
printEvent(color, tool, title)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (part.type === "text") {
|
||||
text = part.text
|
||||
|
||||
if (part.time?.end) {
|
||||
UI.empty()
|
||||
UI.println(UI.markdown(text))
|
||||
UI.empty()
|
||||
text = ""
|
||||
return
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function summarize(response: string) {
|
||||
@@ -936,7 +941,7 @@ export const GithubRunCommand = effectCmd({
|
||||
async function chat(message: string, files: PromptFiles = []) {
|
||||
console.log("Sending message to opencode...")
|
||||
|
||||
return Effect.runPromise(
|
||||
return runLocalEffect(
|
||||
Effect.gen(function* () {
|
||||
const prompt = sessionPrompt
|
||||
const result = yield* prompt.prompt({
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
||||
|
||||
function promptOffsetWidth(value: string) {
|
||||
let width = 0
|
||||
for (const part of graphemes.segment(value)) {
|
||||
// Textarea offsets count newlines as one position; Bun.stringWidth counts them as zero.
|
||||
width += part.segment === "\n" ? 1 : Bun.stringWidth(part.segment)
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
function displayOffsetIndex(value: string, offset: number) {
|
||||
if (offset <= 0) return 0
|
||||
|
||||
let width = 0
|
||||
for (const part of graphemes.segment(value)) {
|
||||
const next = width + Bun.stringWidth(part.segment)
|
||||
const next = width + promptOffsetWidth(part.segment)
|
||||
if (next > offset) return part.index
|
||||
width = next
|
||||
}
|
||||
@@ -13,20 +22,20 @@ function displayOffsetIndex(value: string, offset: number) {
|
||||
return value.length
|
||||
}
|
||||
|
||||
export function displaySlice(value: string, start = 0, end = Bun.stringWidth(value)) {
|
||||
export function displaySlice(value: string, start = 0, end = promptOffsetWidth(value)) {
|
||||
return value.slice(displayOffsetIndex(value, start), displayOffsetIndex(value, end))
|
||||
}
|
||||
|
||||
export function displayCharAt(value: string, offset: number) {
|
||||
let width = 0
|
||||
for (const part of graphemes.segment(value)) {
|
||||
const next = width + Bun.stringWidth(part.segment)
|
||||
const next = width + promptOffsetWidth(part.segment)
|
||||
if (offset === width || offset < next) return part.segment
|
||||
width = next
|
||||
}
|
||||
}
|
||||
|
||||
export function mentionTriggerIndex(value: string, offset = Bun.stringWidth(value)) {
|
||||
export function mentionTriggerIndex(value: string, offset = promptOffsetWidth(value)) {
|
||||
const text = displaySlice(value, 0, offset)
|
||||
const index = text.lastIndexOf("@")
|
||||
if (index === -1) return
|
||||
@@ -34,6 +43,6 @@ export function mentionTriggerIndex(value: string, offset = Bun.stringWidth(valu
|
||||
const before = index === 0 ? undefined : text[index - 1]
|
||||
const query = text.slice(index)
|
||||
if ((before === undefined || /\s/.test(before)) && !/\s/.test(query)) {
|
||||
return Bun.stringWidth(text.slice(0, index))
|
||||
return promptOffsetWidth(text.slice(0, index))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@openc
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Permission } from "@/permission"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { FormatError, FormatUnknownError } from "../error"
|
||||
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin"
|
||||
|
||||
@@ -236,6 +237,7 @@ export const RunCommand = effectCmd({
|
||||
handler: Effect.fn("Cli.run")(function* (args) {
|
||||
const agentSvc = yield* Agent.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const localInstance = yield* InstanceRef
|
||||
yield* Effect.promise(async () => {
|
||||
const rawMessage = [...args.message, ...(args["--"] || [])].join(" ")
|
||||
const thinking = args.interactive ? (args.thinking ?? true) : (args.thinking ?? false)
|
||||
@@ -508,7 +510,9 @@ export const RunCommand = effectCmd({
|
||||
if (!args.agent) return undefined
|
||||
const name = args.agent
|
||||
|
||||
const entry = await Effect.runPromise(agentSvc.get(name))
|
||||
const entry = await Effect.runPromise(
|
||||
agentSvc.get(name).pipe(Effect.provideService(InstanceRef, localInstance)),
|
||||
)
|
||||
if (!entry) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
|
||||
@@ -76,8 +76,6 @@ const appBindingCommands = [
|
||||
"command.palette.show",
|
||||
"session.list",
|
||||
"session.new",
|
||||
"session.cycle_recent",
|
||||
"session.cycle_recent_reverse",
|
||||
"session.quick_switch.1",
|
||||
"session.quick_switch.2",
|
||||
"session.quick_switch.3",
|
||||
@@ -481,37 +479,15 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
|
||||
? [
|
||||
{
|
||||
name: "session.cycle_recent",
|
||||
title: "Cycle to previous recent session",
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
local.session.cycleRecent(1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "session.cycle_recent_reverse",
|
||||
title: "Cycle to next recent session",
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
local.session.cycleRecent(-1)
|
||||
},
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
name: `session.quick_switch.${i + 1}`,
|
||||
title: `Switch to session in quick slot ${i + 1}`,
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
local.session.quickSwitch(i + 1)
|
||||
},
|
||||
})),
|
||||
]
|
||||
: []),
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
name: `session.quick_switch.${i + 1}`,
|
||||
title: `Switch to session in quick slot ${i + 1}`,
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
local.session.quickSwitch(i + 1)
|
||||
},
|
||||
})),
|
||||
{
|
||||
name: "model.list",
|
||||
title: "Switch model",
|
||||
@@ -826,14 +802,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
|
||||
useBindings(() => ({
|
||||
enabled: command.matcher,
|
||||
bindings: tuiConfig.keybinds.gather(
|
||||
"app",
|
||||
Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
|
||||
? appBindingCommands
|
||||
: appBindingCommands.filter(
|
||||
(c) => !c.startsWith("session.cycle_recent") && !c.startsWith("session.quick_switch"),
|
||||
),
|
||||
),
|
||||
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
|
||||
@@ -31,6 +31,8 @@ export function DialogSessionList() {
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
const deleteHint = useCommandShortcut("session.delete")
|
||||
const quickSwitch1 = useCommandShortcut("session.quick_switch.1")
|
||||
const quickSwitch9 = useCommandShortcut("session.quick_switch.9")
|
||||
|
||||
const [searchResults, { refetch }] = createResource(
|
||||
() => ({ query: search(), filter: sync.session.query() }),
|
||||
@@ -130,10 +132,18 @@ export function DialogSessionList() {
|
||||
|
||||
const [browseOrder] = createSignal<string[]>(orderByRecency(sync.data.session))
|
||||
|
||||
const RECENT_LIMIT = 5
|
||||
const quickSwitchHint = createMemo(() => {
|
||||
const first = quickSwitch1()
|
||||
const last = quickSwitch9()
|
||||
if (!first || !last) return undefined
|
||||
return quickSwitchRange(first, last)
|
||||
})
|
||||
const quickSwitchFooterHints = createMemo(() => {
|
||||
const hint = quickSwitchHint()
|
||||
return hint && local.session.slots().length > 0 ? [{ title: "switch", label: hint }] : []
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
const enabled = Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
|
||||
const today = new Date().toDateString()
|
||||
const sessionMap = new Map(
|
||||
sessions()
|
||||
@@ -144,17 +154,9 @@ export function DialogSessionList() {
|
||||
const searchResult = searchResults()
|
||||
const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder()
|
||||
|
||||
const dismissed = enabled ? new Set(local.session.dismissedRecent()) : new Set<string>()
|
||||
const pinned = enabled ? local.session.pinned().filter((id) => sessionMap.has(id)) : []
|
||||
const pinned = local.session.pinned().filter((id) => sessionMap.has(id))
|
||||
const pinnedSet = new Set(pinned)
|
||||
const slotByID = enabled
|
||||
? new Map<string, number>(local.session.slots().map((id, i) => [id, i + 1]))
|
||||
: new Map<string, number>()
|
||||
|
||||
const recent = enabled
|
||||
? displayOrder.filter((id) => !pinnedSet.has(id) && !dismissed.has(id)).slice(0, RECENT_LIMIT)
|
||||
: []
|
||||
const recentSet = new Set(recent)
|
||||
const slotByID = new Map<string, number>(local.session.slots().map((id, i) => [id, i + 1]))
|
||||
|
||||
function buildOption(id: string, category: string) {
|
||||
const x = sessionMap.get(id)
|
||||
@@ -198,7 +200,7 @@ export function DialogSessionList() {
|
||||
}
|
||||
|
||||
const remaining = displayOrder
|
||||
.filter((id) => !pinnedSet.has(id) && !recentSet.has(id))
|
||||
.filter((id) => !pinnedSet.has(id))
|
||||
.map((id) => {
|
||||
const x = sessionMap.get(id)
|
||||
if (!x) return undefined
|
||||
@@ -207,11 +209,7 @@ export function DialogSessionList() {
|
||||
})
|
||||
.filter((x) => x !== undefined)
|
||||
|
||||
return [
|
||||
...pinned.map((id) => buildOption(id, "Pinned")).filter((x) => x !== undefined),
|
||||
...recent.map((id) => buildOption(id, "Recent")).filter((x) => x !== undefined),
|
||||
...remaining,
|
||||
]
|
||||
return [...pinned.map((id) => buildOption(id, "Pinned")).filter((x) => x !== undefined), ...remaining]
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
@@ -236,32 +234,13 @@ export function DialogSessionList() {
|
||||
dialog.clear()
|
||||
}}
|
||||
actions={[
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
|
||||
? [
|
||||
{
|
||||
command: "session.pin.toggle",
|
||||
title: "pin/unpin",
|
||||
onTrigger: (option: { value: string }) => {
|
||||
local.session.togglePin(option.value)
|
||||
},
|
||||
},
|
||||
{
|
||||
command: "session.toggle.recent",
|
||||
title: "toggle recent",
|
||||
onTrigger: (option: { value: string }) => {
|
||||
if (local.session.isPinned(option.value)) {
|
||||
toast.show({
|
||||
variant: "info",
|
||||
message: "Unpin the session first to toggle it in Recent",
|
||||
duration: 3000,
|
||||
})
|
||||
return
|
||||
}
|
||||
local.session.toggleRecent(option.value)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
command: "session.pin.toggle",
|
||||
title: "pin/unpin",
|
||||
onTrigger: (option: { value: string }) => {
|
||||
local.session.togglePin(option.value)
|
||||
},
|
||||
},
|
||||
{
|
||||
command: "session.delete",
|
||||
title: "delete",
|
||||
@@ -318,6 +297,13 @@ export function DialogSessionList() {
|
||||
},
|
||||
},
|
||||
]}
|
||||
footerHints={quickSwitchFooterHints()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function quickSwitchRange(first: string, last: string) {
|
||||
const prefix = first.slice(0, -1)
|
||||
if (first.endsWith("1") && last === `${prefix}9`) return `${prefix}1-9`
|
||||
return `${first} through ${last}`
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@ export type PromptInfo = {
|
||||
|
||||
const MAX_HISTORY_ENTRIES = 50
|
||||
|
||||
export function isDuplicateEntry(previous: PromptInfo | undefined, next: PromptInfo): boolean {
|
||||
if (!previous) return false
|
||||
return JSON.stringify(previous) === JSON.stringify(next)
|
||||
}
|
||||
|
||||
export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({
|
||||
name: "PromptHistory",
|
||||
init: () => {
|
||||
@@ -83,6 +88,10 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
|
||||
},
|
||||
append(item: PromptInfo) {
|
||||
const entry = structuredClone(unwrap(item))
|
||||
if (isDuplicateEntry(store.history.at(-1), entry)) {
|
||||
setStore("index", 0)
|
||||
return
|
||||
}
|
||||
let trimmed = false
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
|
||||
@@ -87,9 +87,6 @@ export const Definitions = {
|
||||
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
|
||||
session_parent: keybind("up", "Go to parent session"),
|
||||
session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"),
|
||||
session_toggle_recent: keybind("ctrl+h", "Show or hide session in the Recent group"),
|
||||
session_cycle_recent: keybind("<leader>]", "Cycle to the previous recent session"),
|
||||
session_cycle_recent_reverse: keybind("<leader>[", "Cycle to the next recent session"),
|
||||
session_quick_switch_1: keybind("<leader>1", "Switch to session in quick slot 1"),
|
||||
session_quick_switch_2: keybind("<leader>2", "Switch to session in quick slot 2"),
|
||||
session_quick_switch_3: keybind("<leader>3", "Switch to session in quick slot 3"),
|
||||
@@ -273,9 +270,6 @@ export const CommandMap = {
|
||||
session_child_cycle_reverse: "session.child.previous",
|
||||
session_parent: "session.parent",
|
||||
session_pin_toggle: "session.pin.toggle",
|
||||
session_toggle_recent: "session.toggle.recent",
|
||||
session_cycle_recent: "session.cycle_recent",
|
||||
session_cycle_recent_reverse: "session.cycle_recent_reverse",
|
||||
session_quick_switch_1: "session.quick_switch.1",
|
||||
session_quick_switch_2: "session.quick_switch.2",
|
||||
session_quick_switch_3: "session.quick_switch.3",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { FormatError } from "@/cli/error"
|
||||
|
||||
/**
|
||||
* Aggregate Promise.allSettled results into a single Error that names every
|
||||
* failed endpoint, or return null when all fulfilled. Used at TUI bootstrap
|
||||
@@ -15,7 +17,19 @@ export function aggregateFailures(labeled: LabeledSettled[]): Error | null {
|
||||
)
|
||||
if (failed.length === 0) return null
|
||||
|
||||
const reasons = failed.map((f) => `${f.name}: ${reasonMessage(f.result.reason)}`).join("; ")
|
||||
const reasons = Array.from(
|
||||
failed
|
||||
.map((f) => ({ name: f.name, message: reasonMessage(f.result.reason) }))
|
||||
.reduce((grouped, failure) => {
|
||||
grouped.set(failure.message, [...(grouped.get(failure.message) ?? []), failure.name])
|
||||
return grouped
|
||||
}, new Map<string, string[]>())
|
||||
.entries(),
|
||||
)
|
||||
.map(([message, names]) =>
|
||||
names.length === 1 ? `${names[0]}: ${message}` : `${message}\nAffected startup requests: ${names.join(", ")}`,
|
||||
)
|
||||
.join("; ")
|
||||
const summary = `${failed.length} of ${labeled.length} requests failed: ${reasons}`
|
||||
const err = new Error(summary)
|
||||
err.cause = { failures: failed.map((f) => ({ name: f.name, reason: f.result.reason })) }
|
||||
@@ -23,6 +37,9 @@ export function aggregateFailures(labeled: LabeledSettled[]): Error | null {
|
||||
}
|
||||
|
||||
function reasonMessage(reason: unknown): string {
|
||||
const formatted = FormatError(reason)
|
||||
if (formatted) return formatted
|
||||
|
||||
if (reason instanceof Error) return reason.message
|
||||
if (typeof reason === "string") return reason
|
||||
if (reason && typeof reason === "object") {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { batch, createEffect, createMemo, on } from "solid-js"
|
||||
import { batch, createEffect, createMemo } from "solid-js"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
@@ -8,7 +8,6 @@ import { useEvent } from "@tui/context/event"
|
||||
import { uniqueBy } from "remeda"
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { iife } from "@/util/iife"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useArgs } from "./args"
|
||||
@@ -387,13 +386,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const [sessionStore, setSessionStore] = createStore<{
|
||||
ready: boolean
|
||||
pinned: string[]
|
||||
dismissedRecent: string[]
|
||||
recentOrder: string[]
|
||||
}>({
|
||||
ready: false,
|
||||
pinned: [],
|
||||
dismissedRecent: [],
|
||||
recentOrder: [],
|
||||
})
|
||||
|
||||
const filePath = path.join(Global.Path.state, "session.json")
|
||||
@@ -409,16 +404,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
state.pending = false
|
||||
void Filesystem.writeJson(filePath, {
|
||||
pinned: sessionStore.pinned,
|
||||
dismissedRecent: sessionStore.dismissedRecent,
|
||||
recentOrder: sessionStore.recentOrder,
|
||||
})
|
||||
}
|
||||
|
||||
Filesystem.readJson(filePath)
|
||||
.then((x: any) => {
|
||||
if (Array.isArray(x.pinned)) setSessionStore("pinned", x.pinned)
|
||||
if (Array.isArray(x.dismissedRecent)) setSessionStore("dismissedRecent", x.dismissedRecent)
|
||||
if (Array.isArray(x.recentOrder)) setSessionStore("recentOrder", x.recentOrder)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
@@ -428,19 +419,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
|
||||
const route = useRoute()
|
||||
const event = useEvent()
|
||||
let cycling = false
|
||||
|
||||
const slots = createMemo(() => {
|
||||
const rootSessions = sync.data.session.filter((x) => x.parentID === undefined)
|
||||
const existing = new Set(rootSessions.map((x) => x.id))
|
||||
const dismissed = new Set(sessionStore.dismissedRecent)
|
||||
const pins = sessionStore.pinned.filter((id) => existing.has(id))
|
||||
const pinnedSet = new Set(pins)
|
||||
const recent = rootSessions
|
||||
.filter((x) => !pinnedSet.has(x.id) && !dismissed.has(x.id))
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
.map((x) => x.id)
|
||||
return [...pins, ...recent].slice(0, 9)
|
||||
const existing = new Set(sync.data.session.filter((x) => x.parentID === undefined).map((x) => x.id))
|
||||
return sessionStore.pinned.filter((id) => existing.has(id)).slice(0, 9)
|
||||
})
|
||||
|
||||
function prune(sessionID: string) {
|
||||
@@ -451,18 +433,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
sessionStore.pinned.filter((x) => x !== sessionID),
|
||||
)
|
||||
}
|
||||
if (sessionStore.dismissedRecent.includes(sessionID)) {
|
||||
setSessionStore(
|
||||
"dismissedRecent",
|
||||
sessionStore.dismissedRecent.filter((x) => x !== sessionID),
|
||||
)
|
||||
}
|
||||
if (sessionStore.recentOrder.includes(sessionID)) {
|
||||
setSessionStore(
|
||||
"recentOrder",
|
||||
sessionStore.recentOrder.filter((x) => x !== sessionID),
|
||||
)
|
||||
}
|
||||
save()
|
||||
})
|
||||
}
|
||||
@@ -471,25 +441,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
prune(evt.properties.info.id)
|
||||
})
|
||||
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING) {
|
||||
createEffect(
|
||||
on(
|
||||
() => (sessionStore.ready && route.data.type === "session" ? route.data.sessionID : undefined),
|
||||
(sessionID) => {
|
||||
if (!sessionID) return
|
||||
if (cycling) {
|
||||
cycling = false
|
||||
return
|
||||
}
|
||||
const filtered = sessionStore.recentOrder.filter((x) => x !== sessionID)
|
||||
const next = [sessionID, ...filtered].slice(0, 20)
|
||||
setSessionStore("recentOrder", next)
|
||||
save()
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
get ready() {
|
||||
return sessionStore.ready
|
||||
@@ -497,75 +448,26 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
pinned() {
|
||||
return sessionStore.pinned
|
||||
},
|
||||
dismissedRecent() {
|
||||
return sessionStore.dismissedRecent
|
||||
},
|
||||
recentOrder() {
|
||||
return sessionStore.recentOrder
|
||||
},
|
||||
slots,
|
||||
isPinned(sessionID: string) {
|
||||
return sessionStore.pinned.includes(sessionID)
|
||||
},
|
||||
isDismissed(sessionID: string) {
|
||||
return sessionStore.dismissedRecent.includes(sessionID)
|
||||
},
|
||||
togglePin(sessionID: string) {
|
||||
batch(() => {
|
||||
const exists = sessionStore.pinned.includes(sessionID)
|
||||
const next = exists
|
||||
? sessionStore.pinned.filter((x) => x !== sessionID)
|
||||
: [sessionID, ...sessionStore.pinned]
|
||||
: [...sessionStore.pinned, sessionID]
|
||||
setSessionStore("pinned", next)
|
||||
save()
|
||||
})
|
||||
},
|
||||
toggleRecent(sessionID: string) {
|
||||
batch(() => {
|
||||
const exists = sessionStore.dismissedRecent.includes(sessionID)
|
||||
const next = exists
|
||||
? sessionStore.dismissedRecent.filter((x) => x !== sessionID)
|
||||
: [sessionID, ...sessionStore.dismissedRecent]
|
||||
setSessionStore("dismissedRecent", next)
|
||||
save()
|
||||
})
|
||||
},
|
||||
quickSwitch(slot: number) {
|
||||
const target = slots()[slot - 1]
|
||||
if (!target) return
|
||||
if (route.data.type === "session" && route.data.sessionID === target) return
|
||||
route.navigate({ type: "session", sessionID: target })
|
||||
},
|
||||
cycleRecent(direction: 1 | -1) {
|
||||
if (route.data.type !== "session") {
|
||||
toast.show({
|
||||
variant: "info",
|
||||
message: "Open a session first to cycle between recent sessions",
|
||||
duration: 3000,
|
||||
})
|
||||
return
|
||||
}
|
||||
const current = route.data.sessionID
|
||||
const order = sessionStore.recentOrder.filter((id) =>
|
||||
sync.data.session.some((s) => s.id === id && s.parentID === undefined),
|
||||
)
|
||||
if (order.length < 2) {
|
||||
toast.show({
|
||||
variant: "info",
|
||||
message: "No other recent sessions to cycle to",
|
||||
duration: 3000,
|
||||
})
|
||||
return
|
||||
}
|
||||
const index = order.indexOf(current)
|
||||
if (index === -1) return
|
||||
const next = index + direction
|
||||
if (next < 0 || next >= order.length) return
|
||||
const target = order[next]
|
||||
if (!target || target === current) return
|
||||
cycling = true
|
||||
route.navigate({ type: "session", sessionID: target })
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createMemo, type Setter } from "solid-js"
|
||||
import { useKV } from "./kv"
|
||||
|
||||
export type ThinkingMode = "show" | "hide"
|
||||
|
||||
const MODES: readonly ThinkingMode[] = ["show", "hide"] as const
|
||||
|
||||
// OpenAI's Responses API surfaces reasoning summaries that start with a bolded
|
||||
// title line: "**Inspecting PR workflow**\n\n<body>". GitHub Copilot routes
|
||||
// through the same shape, and the opencode provider relays it too. Pull the
|
||||
// title out for a nicer label; return null for providers that don't follow
|
||||
// this convention so the caller can fall back to a generic "Thinking" string.
|
||||
export function reasoningTitle(text: string): string | null {
|
||||
const match = text.trimStart().match(/^\*\*([^*\n]+)\*\*/)
|
||||
return match ? match[1].trim() : null
|
||||
}
|
||||
|
||||
export function isThinkingMode(value: unknown): value is ThinkingMode {
|
||||
return typeof value === "string" && (MODES as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
// Cycle order matches the slash command: show → hide → show.
|
||||
export function nextThinkingMode(current: ThinkingMode): ThinkingMode {
|
||||
const idx = MODES.indexOf(current)
|
||||
return MODES[(idx + 1) % MODES.length] ?? "show"
|
||||
}
|
||||
|
||||
export function useThinkingMode() {
|
||||
const kv = useKV()
|
||||
// Capture pre-state before `kv.signal` seeds a default, so we can detect
|
||||
// first-time users with a legacy `thinking_visibility` boolean and migrate.
|
||||
// The KVProvider only renders children once kv.ready, so reads here are safe.
|
||||
const hadStored = kv.get("thinking_mode") !== undefined
|
||||
const legacy = kv.get("thinking_visibility")
|
||||
const [stored, setStored] = kv.signal<ThinkingMode>("thinking_mode", "hide")
|
||||
|
||||
// The kv signal exposes its setter typed as `Setter<T>` which carries Solid's
|
||||
// overload set; passing an updater fn through a property access loses the
|
||||
// bivariance trick the existing `setX((prev) => ...)` callsites rely on.
|
||||
// Wrap it in a sane shape so consumers can just call `set(next)` or pass
|
||||
// an updater.
|
||||
const set = (next: ThinkingMode | ((prev: ThinkingMode) => ThinkingMode)) => {
|
||||
if (typeof next === "function") setStored(next as Setter<ThinkingMode>)
|
||||
else setStored(() => next)
|
||||
}
|
||||
|
||||
// Preserve previous experience for users who had explicitly toggled the
|
||||
// legacy `thinking_visibility` boolean. First-time users (no legacy key)
|
||||
// get the new "hide" default (collapsed thinking).
|
||||
if (!hadStored) {
|
||||
if (legacy === true) set("show")
|
||||
else if (legacy === false) set("hide")
|
||||
}
|
||||
|
||||
if ((stored() as string) === "minimal") set("hide")
|
||||
|
||||
const mode = createMemo<ThinkingMode>(() => {
|
||||
const value = stored()
|
||||
return isThinkingMode(value) ? value : "hide"
|
||||
})
|
||||
|
||||
return {
|
||||
mode,
|
||||
set,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, For, type Accessor } from "solid-js"
|
||||
import { DEFAULT_THEMES, useTheme } from "@tui/context/theme"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { useCommandShortcut } from "../../keymap"
|
||||
|
||||
const themeCount = Object.keys(DEFAULT_THEMES).length
|
||||
@@ -29,8 +28,6 @@ type Shortcuts = {
|
||||
messagesToggleConceal: TipShortcut
|
||||
modelCycleRecent: TipShortcut
|
||||
modelList: TipShortcut
|
||||
sessionCycleRecent: TipShortcut
|
||||
sessionCycleRecentReverse: TipShortcut
|
||||
sessionExport: TipShortcut
|
||||
sessionInterrupt: TipShortcut
|
||||
sessionList: TipShortcut
|
||||
@@ -41,7 +38,6 @@ type Shortcuts = {
|
||||
sessionQuickSwitch9: TipShortcut
|
||||
sessionSidebarToggle: TipShortcut
|
||||
sessionTimeline: TipShortcut
|
||||
sessionToggleRecent: TipShortcut
|
||||
statusView: TipShortcut
|
||||
terminalSuspend: TipShortcut
|
||||
themeList: TipShortcut
|
||||
@@ -73,6 +69,7 @@ function parse(tip: string): TipPart[] {
|
||||
}
|
||||
|
||||
const NO_MODELS_TIP = "Run {highlight}/connect{/highlight} to add an AI provider and start coding"
|
||||
const NO_MODELS_PARTS = parse(NO_MODELS_TIP)
|
||||
|
||||
function shortcutText(value: string) {
|
||||
return `{highlight}${value}{/highlight}`
|
||||
@@ -121,8 +118,6 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
|
||||
messagesToggleConceal: configShortcut(props.api, "session.toggle.conceal"),
|
||||
modelCycleRecent: useCommandShortcut("model.cycle_recent"),
|
||||
modelList: useCommandShortcut("model.list"),
|
||||
sessionCycleRecent: useCommandShortcut("session.cycle_recent"),
|
||||
sessionCycleRecentReverse: useCommandShortcut("session.cycle_recent_reverse"),
|
||||
sessionExport: configShortcut(props.api, "session.export"),
|
||||
sessionInterrupt: configShortcut(props.api, "session.interrupt"),
|
||||
sessionList: useCommandShortcut("session.list"),
|
||||
@@ -133,7 +128,6 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
|
||||
sessionQuickSwitch9: useCommandShortcut("session.quick_switch.9"),
|
||||
sessionSidebarToggle: configShortcut(props.api, "session.sidebar.toggle"),
|
||||
sessionTimeline: configShortcut(props.api, "session.timeline"),
|
||||
sessionToggleRecent: configShortcut(props.api, "session.toggle.recent"),
|
||||
statusView: useCommandShortcut("opencode.status"),
|
||||
terminalSuspend: useCommandShortcut("terminal.suspend"),
|
||||
themeList: useCommandShortcut("theme.switch"),
|
||||
@@ -145,8 +139,13 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
|
||||
return value ? [value] : []
|
||||
})
|
||||
return tips[Math.floor(tipOffset * tips.length)] ?? NO_MODELS_TIP
|
||||
})
|
||||
const parts = createMemo(() => parse(tip()))
|
||||
}, NO_MODELS_TIP)
|
||||
// Solid can expose a memo's initial value while a pure computation is pending.
|
||||
const parts = createMemo(() => {
|
||||
const value = tip()
|
||||
if (typeof value === "string") return parse(value)
|
||||
return NO_MODELS_PARTS
|
||||
}, NO_MODELS_PARTS)
|
||||
|
||||
return (
|
||||
<box flexDirection="row" maxWidth="100%">
|
||||
@@ -176,23 +175,12 @@ const TIPS: Tip[] = [
|
||||
(shortcuts) => `Use ${commandText("/models", shortcuts.modelList())} to see and switch between available AI models`,
|
||||
(shortcuts) => `Use ${commandText("/themes", shortcuts.themeList())} to switch between ${themeCount} built-in themes`,
|
||||
(shortcuts) => `Use ${commandText("/new", shortcuts.sessionNew())} to start a fresh conversation session`,
|
||||
(shortcuts) => `Use ${commandText("/sessions", shortcuts.sessionList())} to list and continue previous conversations`,
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
|
||||
? ([
|
||||
(shortcuts) =>
|
||||
press(shortcuts.sessionPinToggle(), "in the session list to pin a session so it stays at the top"),
|
||||
(shortcuts) =>
|
||||
shortcuts.sessionQuickSwitch1() && shortcuts.sessionQuickSwitch9()
|
||||
? `Pinned and recent sessions are bound to ${shortcutText(shortcuts.sessionQuickSwitch1())} through ${shortcutText(shortcuts.sessionQuickSwitch9())} for one-press switching`
|
||||
: undefined,
|
||||
(shortcuts) =>
|
||||
shortcuts.sessionCycleRecent() && shortcuts.sessionCycleRecentReverse()
|
||||
? `Press ${shortcutText(shortcuts.sessionCycleRecent())} / ${shortcutText(shortcuts.sessionCycleRecentReverse())} to cycle through recently visited sessions`
|
||||
: undefined,
|
||||
(shortcuts) =>
|
||||
press(shortcuts.sessionToggleRecent(), "in the session list to show or hide a session in the Recent group"),
|
||||
] satisfies Tip[])
|
||||
: []),
|
||||
(shortcuts) => `Use ${commandText("/sessions", shortcuts.sessionList())} to list, pin, and continue sessions`,
|
||||
(shortcuts) => press(shortcuts.sessionPinToggle(), "in the session list to pin a session so it stays at the top"),
|
||||
(shortcuts) =>
|
||||
shortcuts.sessionQuickSwitch1() && shortcuts.sessionQuickSwitch9()
|
||||
? `Pinned sessions are assigned quick slots; use ${shortcutText(shortcuts.sessionQuickSwitch1())} through ${shortcutText(shortcuts.sessionQuickSwitch9())} to switch`
|
||||
: undefined,
|
||||
"Run {highlight}/compact{/highlight} to summarize long sessions near context limits",
|
||||
(shortcuts) => `Use ${commandText("/export", shortcuts.sessionExport())} to save the conversation as Markdown`,
|
||||
(shortcuts) => press(shortcuts.messagesCopy(), "to copy the assistant's last message to clipboard"),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SplitBorder } from "@tui/component/border"
|
||||
import { Spinner } from "@tui/component/spinner"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { useLocal } from "@tui/context/local"
|
||||
import { reasoningTitle, useThinkingMode } from "@tui/context/thinking"
|
||||
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
|
||||
import { useBindings } from "../../keymap"
|
||||
@@ -317,7 +318,11 @@ function AssistantMessage(props: {
|
||||
<AssistantText part={part as SessionMessageAssistantText} syntax={props.syntax} />
|
||||
</Match>
|
||||
<Match when={part.type === "reasoning"}>
|
||||
<AssistantReasoning part={part as SessionMessageAssistantReasoning} subtleSyntax={props.subtleSyntax} />
|
||||
<AssistantReasoning
|
||||
part={part as SessionMessageAssistantReasoning}
|
||||
subtleSyntax={props.subtleSyntax}
|
||||
completedAt={() => props.message.time.completed}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={part.type === "tool"}>
|
||||
<AssistantTool part={part as SessionMessageAssistantTool} sessionID={props.sessionID} />
|
||||
@@ -378,30 +383,64 @@ function AssistantText(props: { part: SessionMessageAssistantText; syntax: Synta
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantReasoning(props: { part: SessionMessageAssistantReasoning; subtleSyntax: SyntaxStyle }) {
|
||||
function AssistantReasoning(props: {
|
||||
part: SessionMessageAssistantReasoning
|
||||
subtleSyntax: SyntaxStyle
|
||||
completedAt: () => number | undefined
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
const thinking = useThinkingMode()
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const content = createMemo(() => props.part.text.replace("[REDACTED]", "").trim())
|
||||
const inMinimal = createMemo(() => thinking.mode() === "hide")
|
||||
// v2 reasoning parts have no per-part `time.end` (see SessionMessageAssistantReasoning
|
||||
// in the v2 SDK); we settle on parent-message completion instead.
|
||||
const isDone = createMemo(() => props.completedAt() !== undefined)
|
||||
const title = createMemo(() => reasoningTitle(content()))
|
||||
|
||||
const toggle = () => {
|
||||
if (!inMinimal()) return
|
||||
setExpanded((prev) => !prev)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={content()}>
|
||||
<box
|
||||
paddingLeft={2}
|
||||
marginTop={1}
|
||||
flexDirection="column"
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.backgroundElement}
|
||||
flexShrink={0}
|
||||
>
|
||||
<code
|
||||
filetype="markdown"
|
||||
drawUnstyledText={false}
|
||||
streaming={true}
|
||||
syntaxStyle={props.subtleSyntax}
|
||||
content={"_Thinking:_ " + content()}
|
||||
conceal={true}
|
||||
fg={theme.textMuted}
|
||||
/>
|
||||
</box>
|
||||
<Switch>
|
||||
<Match when={!inMinimal() || expanded()}>
|
||||
<box
|
||||
paddingLeft={2}
|
||||
marginTop={1}
|
||||
flexDirection="column"
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.backgroundElement}
|
||||
flexShrink={0}
|
||||
onMouseUp={toggle}
|
||||
>
|
||||
<code
|
||||
filetype="markdown"
|
||||
drawUnstyledText={false}
|
||||
streaming={true}
|
||||
syntaxStyle={props.subtleSyntax}
|
||||
content={(inMinimal() ? "▼ " : "") + "_Thinking:_ " + content()}
|
||||
conceal={true}
|
||||
fg={theme.textMuted}
|
||||
/>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={isDone()}>
|
||||
<box paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
|
||||
<text fg={theme.textMuted} wrapMode="none">
|
||||
{title() ? "▶ Thought: " + title() : "▶ Thought"}
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<box paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
|
||||
<Spinner color={theme.textMuted}>{title() ? "Thinking: " + title() : "Thinking"}</Spinner>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ import * as Model from "../../util/model"
|
||||
import { formatTranscript } from "../../util/transcript"
|
||||
import { UI } from "@/cli/ui.ts"
|
||||
import { useTuiConfig } from "../../context/tui-config"
|
||||
import { nextThinkingMode, reasoningTitle, useThinkingMode, type ThinkingMode } from "../../context/thinking"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
|
||||
import { DialogRetryAction } from "../../component/dialog-retry-action"
|
||||
@@ -157,6 +158,7 @@ const context = createContext<{
|
||||
width: number
|
||||
sessionID: string
|
||||
conceal: () => boolean
|
||||
thinkingMode: () => ThinkingMode
|
||||
showThinking: () => boolean
|
||||
showTimestamps: () => boolean
|
||||
showDetails: () => boolean
|
||||
@@ -214,7 +216,9 @@ export function Session() {
|
||||
const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto")
|
||||
const [sidebarOpen, setSidebarOpen] = createSignal(false)
|
||||
const [conceal, setConceal] = createSignal(true)
|
||||
const [showThinking, setShowThinking] = kv.signal("thinking_visibility", true)
|
||||
const thinking = useThinkingMode()
|
||||
const thinkingMode = thinking.mode
|
||||
const showThinking = createMemo(() => true)
|
||||
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
|
||||
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
|
||||
const [showAssistantMetadata, _setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
|
||||
@@ -683,7 +687,11 @@ export function Session() {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: showThinking() ? "Hide thinking" : "Show thinking",
|
||||
title: (() => {
|
||||
const next = nextThinkingMode(thinkingMode())
|
||||
if (next === "hide") return "Collapse thinking"
|
||||
return "Expand thinking"
|
||||
})(),
|
||||
value: "session.toggle.thinking",
|
||||
category: "Session",
|
||||
slash: {
|
||||
@@ -691,7 +699,7 @@ export function Session() {
|
||||
aliases: ["toggle-thinking"],
|
||||
},
|
||||
run: () => {
|
||||
setShowThinking((prev) => !prev)
|
||||
thinking.set(nextThinkingMode(thinkingMode()))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -1086,6 +1094,7 @@ export function Session() {
|
||||
},
|
||||
sessionID: route.sessionID,
|
||||
conceal,
|
||||
thinkingMode,
|
||||
showThinking,
|
||||
showTimestamps,
|
||||
showDetails,
|
||||
@@ -1492,32 +1501,77 @@ const PART_MAPPING = {
|
||||
function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
|
||||
const { theme, subtleSyntax } = useTheme()
|
||||
const ctx = use()
|
||||
// Collapsed by default in hide mode: a single line throughout, so the
|
||||
// layout never shifts. Click to open the full markdown block, click to close.
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
|
||||
const content = createMemo(() => {
|
||||
// Filter out redacted reasoning chunks from OpenRouter
|
||||
// OpenRouter sends encrypted reasoning data that appears as [REDACTED]
|
||||
// OpenRouter encrypts some reasoning blocks; drop the placeholder.
|
||||
return props.part.text.replace("[REDACTED]", "").trim()
|
||||
})
|
||||
// Reasoning is finalized when the server sets `time.end` (see processor.ts).
|
||||
// Flips independently of the parent message completing.
|
||||
const isDone = createMemo(() => props.part.time.end !== undefined)
|
||||
const inMinimal = createMemo(() => ctx.thinkingMode() === "hide")
|
||||
const duration = createMemo(() => {
|
||||
const end = props.part.time.end
|
||||
return end === undefined ? 0 : Math.max(0, end - props.part.time.start)
|
||||
})
|
||||
// OpenAI / Copilot / opencode-via-OpenAI emit `**Title**\n\n<body>` summary
|
||||
// blocks. Surface the title both while streaming and after settling so the
|
||||
// collapsed line carries real signal, not just a duration.
|
||||
const title = createMemo(() => reasoningTitle(content()))
|
||||
|
||||
const toggle = () => {
|
||||
if (!inMinimal()) return
|
||||
setExpanded((prev) => !prev)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={content() && ctx.showThinking()}>
|
||||
<box
|
||||
id={"text-" + props.part.id}
|
||||
paddingLeft={2}
|
||||
marginTop={1}
|
||||
flexDirection="column"
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.backgroundElement}
|
||||
>
|
||||
<code
|
||||
filetype="markdown"
|
||||
drawUnstyledText={false}
|
||||
streaming={true}
|
||||
syntaxStyle={subtleSyntax()}
|
||||
content={"_Thinking:_ " + content()}
|
||||
conceal={ctx.conceal()}
|
||||
fg={theme.textMuted}
|
||||
/>
|
||||
</box>
|
||||
<Show when={content()}>
|
||||
<Switch>
|
||||
<Match when={!inMinimal() || expanded()}>
|
||||
{/* Full markdown block: `show` mode, or `hide` after the user opens it. */}
|
||||
<box
|
||||
id={"text-" + props.part.id}
|
||||
paddingLeft={2}
|
||||
marginTop={1}
|
||||
flexDirection="column"
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.backgroundElement}
|
||||
onMouseUp={toggle}
|
||||
>
|
||||
<code
|
||||
filetype="markdown"
|
||||
drawUnstyledText={false}
|
||||
streaming={true}
|
||||
syntaxStyle={subtleSyntax()}
|
||||
content={(inMinimal() ? "▼ " : "") + (isDone() ? "_Thought:_ " : "_Thinking:_ ") + content()}
|
||||
conceal={ctx.conceal()}
|
||||
fg={theme.textMuted}
|
||||
/>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={isDone()}>
|
||||
{/* Settled: ▶ at the start as the click-to-expand cue. */}
|
||||
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
|
||||
<text fg={theme.textMuted} wrapMode="none">
|
||||
{"▶ " +
|
||||
(title()
|
||||
? "Thought: " + title() + " · " + Locale.duration(duration())
|
||||
: "Thought for " + Locale.duration(duration()))}
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
{/* Streaming: leading animated spinner, no disclosure arrow yet — it
|
||||
snaps in once reasoning settles, signalling "done, click to expand". */}
|
||||
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
|
||||
<Spinner color={theme.textMuted}>{title() ? "Thinking: " + title() : "Thinking"}</Spinner>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -1533,6 +1587,7 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
|
||||
streaming={true}
|
||||
internalBlockMode="top-level"
|
||||
content={props.part.text.trim()}
|
||||
tableOptions={{ style: "grid" }}
|
||||
conceal={ctx.conceal()}
|
||||
fg={theme.markdownText}
|
||||
bg={theme.background}
|
||||
|
||||
@@ -38,6 +38,11 @@ export interface DialogSelectProps<T> {
|
||||
disabled?: boolean
|
||||
onTrigger: (option: DialogSelectOption<T>) => void
|
||||
}[]
|
||||
footerHints?: {
|
||||
title: string
|
||||
label: string
|
||||
side?: "left" | "right"
|
||||
}[]
|
||||
bindings?: readonly Binding<Renderable, KeyEvent>[]
|
||||
current?: T
|
||||
}
|
||||
@@ -334,11 +339,12 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
}
|
||||
props.ref?.(ref)
|
||||
|
||||
const visibleActions = createMemo(() =>
|
||||
actions()
|
||||
const visibleActions = createMemo(() => [
|
||||
...actions()
|
||||
.map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" }))
|
||||
.filter((item) => !item.disabled && item.label),
|
||||
)
|
||||
...(props.footerHints ?? []),
|
||||
])
|
||||
const left = createMemo(() => visibleActions().filter((item) => item.side !== "right"))
|
||||
const right = createMemo(() => visibleActions().filter((item) => item.side === "right"))
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Installation } from "@/installation"
|
||||
import { Server } from "@/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { WithInstance } from "@/project/with-instance"
|
||||
import { Rpc } from "@/util/rpc"
|
||||
import { upgrade } from "@/cli/upgrade"
|
||||
import { Config } from "@/config/config"
|
||||
@@ -77,12 +76,8 @@ export const rpc = {
|
||||
return { url: server.url.toString() }
|
||||
},
|
||||
async checkUpgrade(input: { directory: string }) {
|
||||
await WithInstance.provide({
|
||||
directory: input.directory,
|
||||
fn: async () => {
|
||||
await upgrade().catch(() => {})
|
||||
},
|
||||
})
|
||||
await InstanceRuntime.load({ directory: input.directory })
|
||||
await upgrade().catch(() => {})
|
||||
},
|
||||
async reload() {
|
||||
await AppRuntime.runPromise(
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Effect, Schema } from "effect"
|
||||
import { AppRuntime, type AppServices } from "@/effect/app-runtime"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { cmd, type WithDoubleDash } from "./cmd/cmd"
|
||||
|
||||
/**
|
||||
@@ -83,19 +82,11 @@ export const effectCmd = <Args, A>(opts: EffectCmdOpts<Args, A>) =>
|
||||
return
|
||||
}
|
||||
const directory = opts.directory?.(args) ?? process.cwd()
|
||||
// Two-phase: load ctx, then run body inside Instance.current ALS.
|
||||
// Effect's InstanceRef is provided via fiber context, but that context is
|
||||
// lost across `await` inside `Effect.promise(async () => ...)` callbacks
|
||||
// — when handlers re-enter Effect via `AppRuntime.runPromise(svc.method())`
|
||||
// there, attach() falls back to Instance.current ALS, which Node preserves
|
||||
// across awaits. Matches the pre-effectCmd `bootstrap()` behavior.
|
||||
const { store, ctx } = await AppRuntime.runPromise(
|
||||
InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))),
|
||||
)
|
||||
try {
|
||||
await Instance.restore(ctx, () =>
|
||||
AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx))),
|
||||
)
|
||||
await AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx)))
|
||||
} finally {
|
||||
await AppRuntime.runPromise(store.dispose(ctx))
|
||||
}
|
||||
|
||||
@@ -2,16 +2,9 @@ import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { errorFormat } from "@/util/error"
|
||||
import { isRecord } from "@/util/record"
|
||||
|
||||
interface ErrorLike {
|
||||
name?: string
|
||||
_tag?: string
|
||||
message?: string
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type ConfigIssue = { message: string; path: string[] }
|
||||
|
||||
function isTaggedError(error: unknown, tag: string): boolean {
|
||||
function isTaggedError(error: unknown, tag: string): error is Record<string, unknown> {
|
||||
return isRecord(error) && error._tag === tag
|
||||
}
|
||||
|
||||
@@ -39,22 +32,27 @@ function configIssues(input: Record<string, unknown>): ConfigIssue[] {
|
||||
: []
|
||||
}
|
||||
|
||||
export function FormatError(input: unknown) {
|
||||
export function FormatError(input: unknown): string | undefined {
|
||||
if (input instanceof Error && isRecord(input.cause) && "body" in input.cause) {
|
||||
const formatted = FormatError(input.cause.body)
|
||||
if (formatted) return formatted
|
||||
}
|
||||
|
||||
// CliError: domain failure surfaced from an effectCmd handler via fail("...")
|
||||
if (isTaggedError(input, "CliError")) {
|
||||
const data = input as ErrorLike & { exitCode?: number }
|
||||
if (data.exitCode != null) process.exitCode = data.exitCode
|
||||
return data.message ?? ""
|
||||
if (typeof input.exitCode === "number") process.exitCode = input.exitCode
|
||||
return stringField(input, "message") ?? ""
|
||||
}
|
||||
|
||||
// MCPFailed: { name: string }
|
||||
if (NamedError.hasName(input, "MCPFailed")) {
|
||||
return `MCP server "${(input as ErrorLike).data?.name}" failed. Note, opencode does not support MCP authentication yet.`
|
||||
const data = isRecord(input) && isRecord(input.data) ? stringField(input.data, "name") : undefined
|
||||
return `MCP server "${data}" failed. Note, opencode does not support MCP authentication yet.`
|
||||
}
|
||||
|
||||
// AccountServiceError, AccountTransportError: TaggedErrorClass
|
||||
if (isTaggedError(input, "AccountServiceError") || isTaggedError(input, "AccountTransportError")) {
|
||||
return (input as ErrorLike).message ?? ""
|
||||
return stringField(input, "message") ?? ""
|
||||
}
|
||||
|
||||
// ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
|
||||
@@ -64,7 +62,7 @@ export function FormatError(input: unknown) {
|
||||
? providerModelNotFound.suggestions.filter((x) => typeof x === "string")
|
||||
: []
|
||||
return [
|
||||
`Model not found: ${providerModelNotFound.providerID}/${providerModelNotFound.modelID}`,
|
||||
`Model not found: ${stringField(providerModelNotFound, "providerID")}/${stringField(providerModelNotFound, "modelID")}`,
|
||||
...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
|
||||
`Try: \`opencode models\` to list available models`,
|
||||
`Or check your config (opencode.json) provider/model names`,
|
||||
@@ -112,6 +110,7 @@ export function FormatError(input: unknown) {
|
||||
if (isTaggedError(input, "UICancelledError") || NamedError.hasName(input, "UICancelledError")) {
|
||||
return ""
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function FormatUnknownError(input: unknown): string {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import type { InstanceContext } from "@/project/instance"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { Config } from "@/config/config"
|
||||
|
||||
@@ -10,7 +10,6 @@ import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Auth } from "../auth"
|
||||
import { Env } from "../env"
|
||||
import { applyEdits, modify } from "jsonc-parser"
|
||||
import { type InstanceContext } from "../project/instance"
|
||||
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { existsSync } from "fs"
|
||||
import { Account } from "@/account/account"
|
||||
@@ -20,7 +19,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { containsPath } from "../project/instance-context"
|
||||
import { containsPath, type InstanceContext } from "../project/instance-context"
|
||||
import { NonNegativeInt, PositiveInt, type DeepMutable } from "@opencode-ai/core/schema"
|
||||
import { ConfigAgent } from "./agent"
|
||||
import { ConfigAttachment } from "./attachment"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { type WorkspaceAdapter, WorkspaceInfo } from "../types"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { type WorkspaceAdapter, type WorkspaceAdapterContext, WorkspaceInfo } from "../types"
|
||||
|
||||
const WorktreeConfig = Schema.Struct({
|
||||
name: WorkspaceInfo.fields.name,
|
||||
@@ -9,53 +10,81 @@ const WorktreeConfig = Schema.Struct({
|
||||
const decodeWorktreeConfig = Schema.decodeUnknownSync(WorktreeConfig)
|
||||
|
||||
async function loadWorktree() {
|
||||
const [{ AppRuntime }, { Instance }, { Worktree }] = await Promise.all([
|
||||
import("@/effect/app-runtime"),
|
||||
import("@/project/instance"),
|
||||
import("@/worktree"),
|
||||
])
|
||||
return { AppRuntime, Instance, Worktree }
|
||||
const [{ AppRuntime }, { Worktree }] = await Promise.all([import("@/effect/app-runtime"), import("@/worktree")])
|
||||
return { AppRuntime, Worktree }
|
||||
}
|
||||
|
||||
function requireInstance(context: WorkspaceAdapterContext | undefined) {
|
||||
if (!context?.instance) throw new Error("Worktree adapter requires an instance context")
|
||||
return context.instance
|
||||
}
|
||||
|
||||
const provideContext = <A, E, R>(effect: Effect.Effect<A, E, R>, context: WorkspaceAdapterContext | undefined) =>
|
||||
effect.pipe(
|
||||
Effect.provideService(InstanceRef, requireInstance(context)),
|
||||
Effect.provideService(WorkspaceRef, context?.workspaceID),
|
||||
)
|
||||
|
||||
export const WorktreeAdapter: WorkspaceAdapter = {
|
||||
name: "Worktree",
|
||||
description: "Create a git worktree",
|
||||
async configure(info) {
|
||||
async configure(info, context) {
|
||||
const { AppRuntime, Worktree } = await loadWorktree()
|
||||
const next = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo({ detached: true })))
|
||||
const next = await AppRuntime.runPromise(
|
||||
provideContext(
|
||||
Worktree.Service.use((svc) => svc.makeWorktreeInfo({ detached: true })),
|
||||
context,
|
||||
),
|
||||
)
|
||||
return {
|
||||
...info,
|
||||
name: next.name,
|
||||
directory: next.directory,
|
||||
}
|
||||
},
|
||||
async create(info) {
|
||||
async create(info, _env, _from, context) {
|
||||
const { AppRuntime, Worktree } = await loadWorktree()
|
||||
const config = decodeWorktreeConfig(info)
|
||||
await AppRuntime.runPromise(
|
||||
Worktree.Service.use((svc) =>
|
||||
svc.createFromInfo({
|
||||
name: config.name,
|
||||
directory: config.directory,
|
||||
...(config.branch ? { branch: config.branch } : {}),
|
||||
}),
|
||||
provideContext(
|
||||
Worktree.Service.use((svc) =>
|
||||
svc.createFromInfo({
|
||||
name: config.name,
|
||||
directory: config.directory,
|
||||
...(config.branch ? { branch: config.branch } : {}),
|
||||
}),
|
||||
),
|
||||
context,
|
||||
),
|
||||
)
|
||||
},
|
||||
async list() {
|
||||
const { AppRuntime, Instance, Worktree } = await loadWorktree()
|
||||
return (await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.list()))).map((info) => ({
|
||||
async list(context) {
|
||||
const { AppRuntime, Worktree } = await loadWorktree()
|
||||
const ctx = requireInstance(context)
|
||||
return (
|
||||
await AppRuntime.runPromise(
|
||||
provideContext(
|
||||
Worktree.Service.use((svc) => svc.list()),
|
||||
context,
|
||||
),
|
||||
)
|
||||
).map((info) => ({
|
||||
type: "worktree",
|
||||
name: info.name,
|
||||
branch: info.branch,
|
||||
directory: info.directory,
|
||||
projectID: Instance.project.id,
|
||||
projectID: ctx.project.id,
|
||||
}))
|
||||
},
|
||||
async remove(info) {
|
||||
async remove(info, context) {
|
||||
const { AppRuntime, Worktree } = await loadWorktree()
|
||||
const config = decodeWorktreeConfig(info)
|
||||
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove({ directory: config.directory })))
|
||||
await AppRuntime.runPromise(
|
||||
provideContext(
|
||||
Worktree.Service.use((svc) => svc.remove({ directory: config.directory })),
|
||||
context,
|
||||
),
|
||||
)
|
||||
},
|
||||
target(info) {
|
||||
const config = decodeWorktreeConfig(info)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Schema, Struct } from "effect"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
import { WorkspaceID } from "./schema"
|
||||
import type { DeepMutable } from "@opencode-ai/core/schema"
|
||||
|
||||
@@ -37,12 +38,22 @@ export type Target =
|
||||
headers?: HeadersInit
|
||||
}
|
||||
|
||||
export type WorkspaceAdapterContext = {
|
||||
readonly instance?: InstanceContext
|
||||
readonly workspaceID?: WorkspaceID
|
||||
}
|
||||
|
||||
export type WorkspaceAdapter = {
|
||||
name: string
|
||||
description: string
|
||||
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
|
||||
create(info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo): Promise<void>
|
||||
list?(): WorkspaceListedInfo[] | Promise<WorkspaceListedInfo[]>
|
||||
remove(info: WorkspaceInfo): Promise<void>
|
||||
target(info: WorkspaceInfo): Target | Promise<Target>
|
||||
configure(info: WorkspaceInfo, context?: WorkspaceAdapterContext): WorkspaceInfo | Promise<WorkspaceInfo>
|
||||
create(
|
||||
info: WorkspaceInfo,
|
||||
env: Record<string, string | undefined>,
|
||||
from?: WorkspaceInfo,
|
||||
context?: WorkspaceAdapterContext,
|
||||
): Promise<void>
|
||||
list?(context?: WorkspaceAdapterContext): WorkspaceListedInfo[] | Promise<WorkspaceListedInfo[]>
|
||||
remove(info: WorkspaceInfo, context?: WorkspaceAdapterContext): Promise<void>
|
||||
target(info: WorkspaceInfo, context?: WorkspaceAdapterContext): Target | Promise<Target>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Effect } from "effect"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { getAdapter } from "./adapters"
|
||||
import type { WorkspaceAdapter, WorkspaceInfo } from "./types"
|
||||
|
||||
const context = Effect.gen(function* () {
|
||||
return {
|
||||
instance: yield* InstanceRef,
|
||||
workspaceID: yield* WorkspaceRef,
|
||||
}
|
||||
})
|
||||
|
||||
export const target = (info: WorkspaceInfo) =>
|
||||
Effect.gen(function* () {
|
||||
const adapter = getAdapter(info.projectID, info.type)
|
||||
const ctx = yield* context
|
||||
return yield* EffectBridge.fromPromise(() => adapter.target(info, ctx))
|
||||
})
|
||||
|
||||
export const configure = (adapter: WorkspaceAdapter, info: WorkspaceInfo) =>
|
||||
Effect.gen(function* () {
|
||||
const ctx = yield* context
|
||||
return yield* EffectBridge.fromPromise(() => adapter.configure(info, ctx))
|
||||
})
|
||||
|
||||
export const create = (
|
||||
adapter: WorkspaceAdapter,
|
||||
info: WorkspaceInfo,
|
||||
env: Record<string, string | undefined>,
|
||||
from?: WorkspaceInfo,
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const ctx = yield* context
|
||||
return yield* EffectBridge.fromPromise(() => adapter.create(info, env, from, ctx))
|
||||
})
|
||||
|
||||
export const list = (adapter: WorkspaceAdapter) =>
|
||||
Effect.gen(function* () {
|
||||
const ctx = yield* context
|
||||
return yield* EffectBridge.fromPromise(() => Promise.resolve(adapter.list?.(ctx) ?? []))
|
||||
})
|
||||
|
||||
export const remove = (info: WorkspaceInfo) =>
|
||||
Effect.gen(function* () {
|
||||
const adapter = getAdapter(info.projectID, info.type)
|
||||
const ctx = yield* context
|
||||
return yield* EffectBridge.fromPromise(() => adapter.remove(info, ctx))
|
||||
})
|
||||
|
||||
export * as WorkspaceAdapterRuntime from "./workspace-adapter-runtime"
|
||||
@@ -26,11 +26,11 @@ import { SessionID } from "@/session/schema"
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
import { errorData } from "@/util/error"
|
||||
import { waitEvent } from "./util"
|
||||
import { WorkspaceContext } from "./workspace-context"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { WorkspaceAdapterRuntime } from "./workspace-adapter-runtime"
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
...WorkspaceInfoSchema.fields,
|
||||
@@ -281,8 +281,7 @@ export const layer = Layer.effect(
|
||||
const workspace = yield* get(input.workspaceID)
|
||||
if (!workspace) return input.fallback
|
||||
|
||||
const adapter = getAdapter(workspace.projectID, workspace.type)
|
||||
const target = yield* EffectBridge.fromPromise(() => adapter.target(workspace))
|
||||
const target = yield* WorkspaceAdapterRuntime.target(workspace)
|
||||
|
||||
if (target.type === "local") {
|
||||
const store = yield* InstanceStore.Service
|
||||
@@ -375,35 +374,27 @@ export const layer = Layer.effect(
|
||||
events: events.length,
|
||||
})
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await WorkspaceContext.provide({
|
||||
workspaceID: space.id,
|
||||
async fn() {
|
||||
await Effect.runPromise(
|
||||
Effect.forEach(
|
||||
events,
|
||||
(event) =>
|
||||
sync.replay(
|
||||
{
|
||||
id: event.id,
|
||||
aggregateID: event.aggregate_id,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
data: event.data,
|
||||
},
|
||||
{ publish: true },
|
||||
),
|
||||
{ discard: true },
|
||||
),
|
||||
yield* Effect.forEach(
|
||||
events,
|
||||
(event) =>
|
||||
sync
|
||||
.replay(
|
||||
{
|
||||
id: event.id,
|
||||
aggregateID: event.aggregate_id,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
data: event.data,
|
||||
},
|
||||
{ publish: true },
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
.pipe(Effect.provideService(WorkspaceRef, space.id)),
|
||||
{ discard: true },
|
||||
)
|
||||
})
|
||||
|
||||
const syncWorkspaceLoop = Effect.fn("Workspace.syncWorkspaceLoop")(function* (space: Info) {
|
||||
const adapter = getAdapter(space.projectID, space.type)
|
||||
const target = yield* EffectBridge.fromPromise(() => adapter.target(space))
|
||||
const target = yield* WorkspaceAdapterRuntime.target(space)
|
||||
|
||||
if (target.type === "local") return
|
||||
|
||||
@@ -486,8 +477,7 @@ export const layer = Layer.effect(
|
||||
const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) {
|
||||
if (!flags.experimentalWorkspaces) return
|
||||
|
||||
const adapter = getAdapter(space.projectID, space.type)
|
||||
const target = yield* EffectBridge.fromPromise(() => adapter.target(space)).pipe(
|
||||
const target = yield* WorkspaceAdapterRuntime.target(space).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
setStatus(space.id, "error")
|
||||
@@ -538,15 +528,13 @@ export const layer = Layer.effect(
|
||||
const create = Effect.fn("Workspace.create")(function* (input: CreateInput) {
|
||||
const id = WorkspaceID.ascending(input.id)
|
||||
const adapter = getAdapter(input.projectID, input.type)
|
||||
const config = yield* EffectBridge.fromPromise(() =>
|
||||
adapter.configure({
|
||||
...input,
|
||||
id,
|
||||
name: Slug.create(),
|
||||
directory: null,
|
||||
extra: input.extra ?? null,
|
||||
}),
|
||||
)
|
||||
const config = yield* WorkspaceAdapterRuntime.configure(adapter, {
|
||||
...input,
|
||||
id,
|
||||
name: Slug.create(),
|
||||
directory: null,
|
||||
extra: input.extra ?? null,
|
||||
})
|
||||
|
||||
const info: Info = {
|
||||
id,
|
||||
@@ -583,7 +571,7 @@ export const layer = Layer.effect(
|
||||
OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES,
|
||||
}
|
||||
|
||||
yield* EffectBridge.fromPromise(() => adapter.create(config, env))
|
||||
yield* WorkspaceAdapterRuntime.create(adapter, config, env)
|
||||
yield* Effect.all(
|
||||
[
|
||||
waitEvent({
|
||||
@@ -622,8 +610,7 @@ export const layer = Layer.effect(
|
||||
if (current?.workspaceID) {
|
||||
const previous = yield* get(current.workspaceID)
|
||||
if (previous) {
|
||||
const adapter = getAdapter(previous.projectID, previous.type)
|
||||
const target = yield* EffectBridge.fromPromise(() => adapter.target(previous))
|
||||
const target = yield* WorkspaceAdapterRuntime.target(previous)
|
||||
|
||||
if (target.type === "remote") {
|
||||
yield* syncHistory(previous, target.url, target.headers).pipe(
|
||||
@@ -701,8 +688,7 @@ export const layer = Layer.effect(
|
||||
workspaceID,
|
||||
})
|
||||
|
||||
const adapter = getAdapter(space.projectID, space.type)
|
||||
const target = yield* EffectBridge.fromPromise(() => adapter.target(space))
|
||||
const target = yield* WorkspaceAdapterRuntime.target(space)
|
||||
|
||||
if (target.type === "local") {
|
||||
yield* sync.run(Session.Event.Updated, {
|
||||
@@ -855,16 +841,14 @@ export const layer = Layer.effect(
|
||||
const discovered = yield* Effect.forEach(
|
||||
registeredAdapters(project.id),
|
||||
([type, adapter]) =>
|
||||
adapter.list
|
||||
? EffectBridge.fromPromise(() => Promise.resolve(adapter.list?.() ?? [])).pipe(
|
||||
Effect.catchCause((error) =>
|
||||
Effect.sync(() => {
|
||||
log.warn("workspace adapter list failed", { type, error })
|
||||
return []
|
||||
}),
|
||||
),
|
||||
)
|
||||
: Effect.succeed([]),
|
||||
WorkspaceAdapterRuntime.list(adapter).pipe(
|
||||
Effect.catchCause((error) =>
|
||||
Effect.sync(() => {
|
||||
log.warn("workspace adapter list failed", { type, error })
|
||||
return []
|
||||
}),
|
||||
),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.map((items) => items.flat()))
|
||||
|
||||
@@ -937,8 +921,7 @@ export const layer = Layer.effect(
|
||||
const info = fromRow(row)
|
||||
yield* Effect.catchCause(
|
||||
Effect.gen(function* () {
|
||||
const adapter = getAdapter(info.projectID, row.type)
|
||||
yield* EffectBridge.fromPromise(() => adapter.remove(info))
|
||||
yield* WorkspaceAdapterRuntime.remove(info)
|
||||
}),
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Effect, Exit, Fiber } from "effect"
|
||||
import { Context, Effect, Exit, Fiber } from "effect"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { Instance, type InstanceContext } from "@/project/instance"
|
||||
import type { WorkspaceID } from "@/control-plane/schema"
|
||||
import { LocalContext } from "@/util/local-context"
|
||||
import { InstanceRef, WorkspaceRef } from "./instance-ref"
|
||||
import { attachWith } from "./run-service"
|
||||
|
||||
@@ -10,67 +8,75 @@ export interface Shape {
|
||||
readonly promise: <A, E, R>(effect: Effect.Effect<A, E, R>) => Promise<A>
|
||||
readonly fork: <A, E, R>(effect: Effect.Effect<A, E, R>) => Fiber.Fiber<A, E>
|
||||
readonly run: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E>
|
||||
readonly bind: <Args extends readonly unknown[], Result>(fn: (...args: Args) => Result) => (...args: Args) => Result
|
||||
}
|
||||
|
||||
function restore<R>(instance: InstanceContext | undefined, workspace: WorkspaceID | undefined, fn: () => R): R {
|
||||
if (instance && workspace !== undefined) {
|
||||
return WorkspaceContext.restore(workspace, () => Instance.restore(instance, fn))
|
||||
}
|
||||
if (instance) return Instance.restore(instance, fn)
|
||||
function restoreWorkspace<R>(workspace: WorkspaceID | undefined, fn: () => R): R {
|
||||
if (workspace !== undefined) return WorkspaceContext.restore(workspace, fn)
|
||||
return fn()
|
||||
}
|
||||
|
||||
function captureSync() {
|
||||
const fiber = Fiber.getCurrent()
|
||||
const instance = fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined
|
||||
const workspace =
|
||||
(fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined) ?? WorkspaceContext.workspaceID
|
||||
return { instance, workspace }
|
||||
}
|
||||
|
||||
export const bind = <Args extends readonly unknown[], Result>(fn: (...args: Args) => Result) => {
|
||||
const captured = captureSync()
|
||||
return (...args: Args) =>
|
||||
restoreWorkspace(captured.workspace, () =>
|
||||
Effect.runSync(
|
||||
attachWith(
|
||||
Effect.sync(() => fn(...args)),
|
||||
captured,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge from Effect into a Promise-returning JS callback while installing
|
||||
* legacy `Instance.context` and `WorkspaceContext` AsyncLocalStorage for
|
||||
* the duration of the callback. Effect's `InstanceRef`/`WorkspaceRef` do
|
||||
* not propagate across async/await boundaries inside `Effect.promise(() =>
|
||||
* async fn)` callbacks that re-enter Effect via `AppRuntime.runPromise`,
|
||||
* but Node's AsyncLocalStorage does. Use this whenever an Effect crosses
|
||||
* into JS that may itself spawn new Effect runtimes (workspace adapters,
|
||||
* legacy plugins, etc.).
|
||||
* Bridge from Effect into a Promise-returning JS callback while preserving
|
||||
* `WorkspaceContext` AsyncLocalStorage for callback code that still reads it.
|
||||
* `InstanceRef` is captured for effects run through the returned bridge APIs;
|
||||
* plain JS callbacks that need it should receive the ref explicitly.
|
||||
*
|
||||
* Mirrors `Effect.promise` but restores legacy ALS first.
|
||||
* Mirrors `Effect.promise` but restores workspace ALS first.
|
||||
*/
|
||||
export const fromPromise = <T>(fn: () => Promise<T> | T): Effect.Effect<T> =>
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceRef
|
||||
const workspace = yield* WorkspaceRef
|
||||
return yield* Effect.promise(() => Promise.resolve(restore(instance, workspace, () => fn())))
|
||||
return yield* Effect.promise(() => Promise.resolve(restoreWorkspace(workspace, () => fn())))
|
||||
})
|
||||
|
||||
export function make(): Effect.Effect<Shape> {
|
||||
return Effect.gen(function* () {
|
||||
const ctx = yield* Effect.context()
|
||||
const value = yield* InstanceRef
|
||||
const instance =
|
||||
value ??
|
||||
(() => {
|
||||
try {
|
||||
return Instance.current
|
||||
} catch (err) {
|
||||
if (!(err instanceof LocalContext.NotFound)) throw err
|
||||
}
|
||||
})()
|
||||
const workspace = (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID
|
||||
const attach = <A, E, R>(effect: Effect.Effect<A, E, R>) => attachWith(effect, { instance, workspace })
|
||||
const captured = captureSync()
|
||||
const instance = (yield* InstanceRef) ?? captured.instance
|
||||
const workspace = (yield* WorkspaceRef) ?? captured.workspace
|
||||
const wrap = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
attach(effect).pipe(Effect.provide(ctx)) as Effect.Effect<A, E, never>
|
||||
attachWith(effect.pipe(Effect.provide(ctx)) as Effect.Effect<A, E, never>, { instance, workspace })
|
||||
|
||||
return {
|
||||
promise: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
restore(instance, workspace, () => Effect.runPromise(wrap(effect))),
|
||||
restoreWorkspace(workspace, () => Effect.runPromise(wrap(effect))),
|
||||
fork: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
restore(instance, workspace, () => Effect.runFork(wrap(effect))),
|
||||
restoreWorkspace(workspace, () => Effect.runFork(wrap(effect))),
|
||||
run: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.callback<A, E>((resume) => {
|
||||
restore(instance, workspace, () =>
|
||||
restoreWorkspace(workspace, () =>
|
||||
Effect.runPromiseExit(wrap(effect)).then((exit) =>
|
||||
resume(Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
bind:
|
||||
<Args extends readonly unknown[], Result>(fn: (...args: Args) => Result) =>
|
||||
(...args: Args) =>
|
||||
restoreWorkspace(workspace, () => Effect.runSync(wrap(Effect.sync(() => fn(...args))))),
|
||||
} satisfies Shape
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Context } from "effect"
|
||||
import type { InstanceContext } from "@/project/instance"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
import type { WorkspaceID } from "@/control-plane/schema"
|
||||
|
||||
export const InstanceRef = Context.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Effect, Fiber, ScopedCache, Scope, Context } from "effect"
|
||||
import { Effect, ScopedCache, Scope } from "effect"
|
||||
import * as EffectLogger from "@opencode-ai/core/effect/logger"
|
||||
import { Instance, type InstanceContext } from "@/project/instance"
|
||||
import { LocalContext } from "@/util/local-context"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
import { InstanceRef, WorkspaceRef } from "./instance-ref"
|
||||
import { registerDisposer } from "./instance-registry"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
@@ -13,20 +12,10 @@ export interface InstanceState<A, E = never, R = never> {
|
||||
readonly cache: ScopedCache.ScopedCache<string, A, E, R>
|
||||
}
|
||||
|
||||
export const bind = <F extends (...args: any[]) => any>(fn: F): F => {
|
||||
try {
|
||||
return Instance.bind(fn)
|
||||
} catch (err) {
|
||||
if (!(err instanceof LocalContext.NotFound)) throw err
|
||||
}
|
||||
const fiber = Fiber.getCurrent()
|
||||
const ctx = fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined
|
||||
if (!ctx) return fn
|
||||
return ((...args: any[]) => Instance.restore(ctx, () => fn(...args))) as F
|
||||
}
|
||||
|
||||
export const context = Effect.gen(function* () {
|
||||
return (yield* InstanceRef) ?? Instance.current
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return yield* Effect.die(new Error("InstanceRef not provided"))
|
||||
return ctx
|
||||
})
|
||||
|
||||
export const workspaceID = Effect.gen(function* () {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Effect, Fiber, Layer, ManagedRuntime } from "effect"
|
||||
import * as Context from "effect/Context"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { LocalContext } from "@/util/local-context"
|
||||
import { InstanceRef, WorkspaceRef } from "./instance-ref"
|
||||
import * as Observability from "@opencode-ai/core/effect/observability"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import type { InstanceContext } from "@/project/instance"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
|
||||
type Refs = {
|
||||
@@ -25,17 +23,9 @@ export function attachWith<A, E, R>(effect: Effect.Effect<A, E, R>, refs: Refs):
|
||||
|
||||
export function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
|
||||
const workspace = WorkspaceContext.workspaceID
|
||||
const instance = (() => {
|
||||
try {
|
||||
return Instance.current
|
||||
} catch (err) {
|
||||
if (!(err instanceof LocalContext.NotFound)) throw err
|
||||
}
|
||||
})()
|
||||
if (instance && workspace !== undefined) return attachWith(effect, { instance, workspace })
|
||||
const fiber = Fiber.getCurrent()
|
||||
return attachWith(effect, {
|
||||
instance: instance ?? (fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined),
|
||||
instance: fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined,
|
||||
workspace: workspace ?? (fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,6 +17,13 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
|
||||
disableDefaultPlugins: bool("OPENCODE_DISABLE_DEFAULT_PLUGINS"),
|
||||
disableChannelDb: bool("OPENCODE_DISABLE_CHANNEL_DB"),
|
||||
disableEmbeddedWebUi: bool("OPENCODE_DISABLE_EMBEDDED_WEB_UI"),
|
||||
disableExternalSkills: bool("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
|
||||
disableLspDownload: bool("OPENCODE_DISABLE_LSP_DOWNLOAD"),
|
||||
skipMigrations: bool("OPENCODE_SKIP_MIGRATIONS"),
|
||||
disableClaudeCodePrompt: Config.all({
|
||||
broad: bool("OPENCODE_DISABLE_CLAUDE_CODE"),
|
||||
direct: bool("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
|
||||
}).pipe(Config.map((flags) => flags.broad || flags.direct)),
|
||||
disableClaudeCodeSkills: Config.all({
|
||||
broad: bool("OPENCODE_DISABLE_CLAUDE_CODE"),
|
||||
direct: bool("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS"),
|
||||
@@ -41,6 +48,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
|
||||
experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
|
||||
experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
|
||||
experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
|
||||
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
|
||||
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
|
||||
client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
|
||||
}) {}
|
||||
|
||||
@@ -2,10 +2,11 @@ import { Cause, Effect, Layer, Context, Schema } from "effect"
|
||||
// @ts-ignore
|
||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
import type ParcelWatcher from "@parcel/watcher"
|
||||
import { readdir } from "fs/promises"
|
||||
import { readdir, realpath } from "fs/promises"
|
||||
import path from "path"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Git } from "@/git"
|
||||
@@ -88,13 +89,13 @@ export const layer = Layer.effect(
|
||||
if (!w) return
|
||||
|
||||
log.info("watcher backend", { directory: ctx.directory, platform: process.platform, backend })
|
||||
|
||||
const bridge = yield* EffectBridge.make()
|
||||
const subs: ParcelWatcher.AsyncSubscription[] = []
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
|
||||
)
|
||||
|
||||
const cb: ParcelWatcher.SubscribeCallback = InstanceState.bind((err, evts) => {
|
||||
const cb: ParcelWatcher.SubscribeCallback = bridge.bind((err, evts) => {
|
||||
if (err) return
|
||||
for (const evt of evts) {
|
||||
if (evt.type === "create") void Bus.publish(Event.Updated, { file: evt.path, event: "add" })
|
||||
@@ -131,8 +132,14 @@ export const layer = Layer.effect(
|
||||
const result = yield* git.run(["rev-parse", "--git-dir"], {
|
||||
cwd: ctx.worktree,
|
||||
})
|
||||
const vcsDir = result.exitCode === 0 ? path.resolve(ctx.worktree, result.text().trim()) : undefined
|
||||
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
|
||||
const resolved = result.exitCode === 0 ? path.resolve(ctx.worktree, result.text().trim()) : undefined
|
||||
const vcsDir = resolved ? yield* Effect.promise(() => realpath(resolved).catch(() => resolved)) : undefined
|
||||
if (
|
||||
vcsDir &&
|
||||
!cfgIgnores.includes(".git") &&
|
||||
!cfgIgnores.includes(vcsDir) &&
|
||||
(!resolved || !cfgIgnores.includes(resolved))
|
||||
) {
|
||||
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
|
||||
(entry) => entry !== "HEAD",
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import type { InstanceContext } from "../project/instance"
|
||||
import type { InstanceContext } from "../project/instance-context"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Process } from "@/util/process"
|
||||
import { which } from "../util/which"
|
||||
|
||||
@@ -7,10 +7,13 @@ import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Process } from "@/util/process"
|
||||
import { LANGUAGE_EXTENSIONS } from "./language"
|
||||
import { Schema } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import type * as LSPServer from "./server"
|
||||
import { withTimeout } from "../util/timeout"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
|
||||
const DIAGNOSTICS_DEBOUNCE_MS = 150
|
||||
const DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS = 5_000
|
||||
@@ -25,6 +28,7 @@ const FILE_CHANGE_CHANGED = 2
|
||||
const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2
|
||||
|
||||
const log = Log.create({ service: "lsp.client" })
|
||||
const busRuntime = makeRuntime(Bus.Service, Bus.layer)
|
||||
|
||||
export type Info = NonNullable<Awaited<ReturnType<typeof create>>>
|
||||
|
||||
@@ -134,9 +138,16 @@ function shouldSeedDiagnosticsOnFirstPush(serverID: string) {
|
||||
return serverID === "typescript"
|
||||
}
|
||||
|
||||
export async function create(input: { serverID: string; server: LSPServer.Handle; root: string; directory: string }) {
|
||||
export async function create(input: {
|
||||
serverID: string
|
||||
server: LSPServer.Handle
|
||||
root: string
|
||||
directory: string
|
||||
instance: InstanceContext
|
||||
}) {
|
||||
const logger = log.clone().tag("serverID", input.serverID)
|
||||
logger.info("starting client")
|
||||
const instance = input.instance
|
||||
|
||||
const connection = createMessageConnection(
|
||||
new StreamMessageReader(input.server.process.stdout as any),
|
||||
@@ -162,7 +173,11 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
|
||||
dedupeDiagnostics([...(pushDiagnostics.get(filePath) ?? []), ...(pullDiagnostics.get(filePath) ?? [])])
|
||||
const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => {
|
||||
pushDiagnostics.set(filePath, next)
|
||||
Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })
|
||||
void busRuntime.runPromise((svc) =>
|
||||
svc
|
||||
.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })
|
||||
.pipe(Effect.provideService(InstanceRef, instance)),
|
||||
)
|
||||
}
|
||||
const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => {
|
||||
pullDiagnostics.set(filePath, next)
|
||||
@@ -510,10 +525,14 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
|
||||
}
|
||||
|
||||
timeoutTimer = setTimeout(() => finish(false), request.timeout)
|
||||
unsub = Bus.subscribe(Event.Diagnostics, (event) => {
|
||||
if (event.properties.path !== request.path || event.properties.serverID !== input.serverID) return
|
||||
schedule()
|
||||
})
|
||||
unsub = busRuntime.runSync((svc) =>
|
||||
svc
|
||||
.subscribeCallback(Event.Diagnostics, (event) => {
|
||||
if (event.properties.path !== request.path || event.properties.serverID !== input.serverID) return
|
||||
schedule()
|
||||
})
|
||||
.pipe(Effect.provideService(InstanceRef, instance)),
|
||||
)
|
||||
schedule()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -237,6 +237,7 @@ export const layer = Layer.effect(
|
||||
server: handle,
|
||||
root,
|
||||
directory: ctx.directory,
|
||||
instance: ctx,
|
||||
}).catch(async (err) => {
|
||||
s.broken.add(key)
|
||||
await Process.stop(handle.process)
|
||||
|
||||
@@ -6,8 +6,7 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||
import { text } from "node:stream/consumers"
|
||||
import fs from "fs/promises"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import type { InstanceContext } from "../project/instance"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import type { InstanceContext } from "../project/instance-context"
|
||||
import { Archive } from "@/util/archive"
|
||||
import { Process } from "@/util/process"
|
||||
import { which } from "../util/which"
|
||||
@@ -126,11 +125,11 @@ export const Vue: Info = {
|
||||
id: "vue",
|
||||
extensions: [".vue"],
|
||||
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
|
||||
async spawn(root) {
|
||||
async spawn(root, _ctx, flags) {
|
||||
let binary = which("vue-language-server")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
if (flags.disableLspDownload) return
|
||||
const resolved = await Npm.which("@vue/language-server")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
@@ -155,13 +154,13 @@ export const ESLint: Info = {
|
||||
id: "eslint",
|
||||
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"],
|
||||
async spawn(root, ctx) {
|
||||
async spawn(root, ctx, flags) {
|
||||
const eslint = Module.resolve("eslint", ctx.directory)
|
||||
if (!eslint) return
|
||||
log.info("spawning eslint server")
|
||||
const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js")
|
||||
if (!(await Filesystem.exists(serverPath))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
if (flags.disableLspDownload) return
|
||||
log.info("downloading and building VS Code ESLint server")
|
||||
const response = await fetch("https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip")
|
||||
if (!response.ok) return
|
||||
@@ -351,11 +350,11 @@ export const Gopls: Info = {
|
||||
return NearestRoot(["go.mod", "go.sum"])(file, ctx)
|
||||
},
|
||||
extensions: [".go"],
|
||||
async spawn(root) {
|
||||
async spawn(root, _ctx, flags) {
|
||||
let bin = which("gopls")
|
||||
if (!bin) {
|
||||
if (!which("go")) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
if (flags.disableLspDownload) return
|
||||
|
||||
log.info("installing gopls")
|
||||
const proc = Process.spawn(["go", "install", "golang.org/x/tools/gopls@latest"], {
|
||||
@@ -386,7 +385,7 @@ export const Rubocop: Info = {
|
||||
id: "ruby-lsp",
|
||||
root: NearestRoot(["Gemfile"]),
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async spawn(root) {
|
||||
async spawn(root, _ctx, flags) {
|
||||
let bin = which("rubocop")
|
||||
if (!bin) {
|
||||
const ruby = which("ruby")
|
||||
@@ -395,7 +394,7 @@ export const Rubocop: Info = {
|
||||
log.info("Ruby not found, please install Ruby first")
|
||||
return
|
||||
}
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
if (flags.disableLspDownload) return
|
||||
log.info("installing rubocop")
|
||||
const proc = Process.spawn(["gem", "install", "rubocop", "--bindir", Global.Path.bin], {
|
||||
stdout: "pipe",
|
||||
@@ -486,11 +485,11 @@ export const Pyright: Info = {
|
||||
id: "pyright",
|
||||
extensions: [".py", ".pyi"],
|
||||
root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]),
|
||||
async spawn(root) {
|
||||
async spawn(root, _ctx, flags) {
|
||||
let binary = which("pyright-langserver")
|
||||
const args = []
|
||||
if (!binary) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
if (flags.disableLspDownload) return
|
||||
const resolved = await Npm.which("pyright", "pyright-langserver")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
@@ -530,7 +529,7 @@ export const ElixirLS: Info = {
|
||||
id: "elixir-ls",
|
||||
extensions: [".ex", ".exs"],
|
||||
root: NearestRoot(["mix.exs", "mix.lock"]),
|
||||
async spawn(root) {
|
||||
async spawn(root, _ctx, flags) {
|
||||
let binary = which("elixir-ls")
|
||||
if (!binary) {
|
||||
const elixirLsPath = path.join(Global.Path.bin, "elixir-ls")
|
||||
@@ -548,7 +547,7 @@ export const ElixirLS: Info = {
|
||||
return
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
if (flags.disableLspDownload) return
|
||||
log.info("downloading elixir-ls from GitHub releases")
|
||||
|
||||
const response = await fetch("https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip")
|
||||
@@ -593,7 +592,7 @@ export const Zls: Info = {
|
||||
id: "zls",
|
||||
extensions: [".zig", ".zon"],
|
||||
root: NearestRoot(["build.zig"]),
|
||||
async spawn(root) {
|
||||
async spawn(root, _ctx, flags) {
|
||||
let bin = which("zls")
|
||||
|
||||
if (!bin) {
|
||||
@@ -603,7 +602,7 @@ export const Zls: Info = {
|
||||
return
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
if (flags.disableLspDownload) return
|
||||
log.info("downloading zls from GitHub releases")
|
||||
|
||||
const releaseResponse = await fetch("https://api.github.com/repos/zigtools/zls/releases/latest")
|
||||
@@ -705,8 +704,8 @@ export const CSharp: Info = {
|
||||
id: "csharp",
|
||||
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
|
||||
extensions: [".cs", ".csx"],
|
||||
async spawn(root) {
|
||||
const bin = await getRoslynLanguageServer()
|
||||
async spawn(root, _ctx, flags) {
|
||||
const bin = await getRoslynLanguageServer(flags.disableLspDownload)
|
||||
if (!bin) return
|
||||
|
||||
return {
|
||||
@@ -721,8 +720,8 @@ export const Razor: Info = {
|
||||
id: "razor",
|
||||
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
|
||||
extensions: [".razor", ".cshtml"],
|
||||
async spawn(root) {
|
||||
const bin = await getRoslynLanguageServer()
|
||||
async spawn(root, _ctx, flags) {
|
||||
const bin = await getRoslynLanguageServer(flags.disableLspDownload)
|
||||
if (!bin) return
|
||||
|
||||
const razor = await findVscodeRazorExtension()
|
||||
@@ -753,26 +752,26 @@ export const Razor: Info = {
|
||||
|
||||
let roslynLanguageServerInstall: Promise<string | undefined> | undefined
|
||||
|
||||
async function getRoslynLanguageServer() {
|
||||
async function getRoslynLanguageServer(disableLspDownload: boolean) {
|
||||
const existing = which("roslyn-language-server")
|
||||
if (existing) return existing
|
||||
|
||||
const global = await roslynLanguageServerGlobalPath()
|
||||
if (global) return global
|
||||
|
||||
roslynLanguageServerInstall ||= installRoslynLanguageServer().finally(() => {
|
||||
roslynLanguageServerInstall ||= installRoslynLanguageServer(disableLspDownload).finally(() => {
|
||||
roslynLanguageServerInstall = undefined
|
||||
})
|
||||
return roslynLanguageServerInstall
|
||||
}
|
||||
|
||||
async function installRoslynLanguageServer() {
|
||||
async function installRoslynLanguageServer(disableLspDownload: boolean) {
|
||||
if (!which("dotnet")) {
|
||||
log.error(".NET SDK is required to install roslyn-language-server")
|
||||
return
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
if (disableLspDownload) return
|
||||
log.info("installing roslyn-language-server via dotnet tool")
|
||||
const proc = Process.spawn(["dotnet", "tool", "install", "--global", "roslyn-language-server", "--prerelease"], {
|
||||
stdout: "pipe",
|
||||
@@ -850,7 +849,7 @@ export const FSharp: Info = {
|
||||
id: "fsharp",
|
||||
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
|
||||
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
|
||||
async spawn(root) {
|
||||
async spawn(root, _ctx, flags) {
|
||||
let bin = which("fsautocomplete")
|
||||
if (!bin) {
|
||||
if (!which("dotnet")) {
|
||||
@@ -858,7 +857,7 @@ export const FSharp: Info = {
|
||||
return
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
if (flags.disableLspDownload) return
|
||||
log.info("installing fsautocomplete via dotnet tool")
|
||||
const proc = Process.spawn(["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin], {
|
||||
stdout: "pipe",
|
||||
@@ -967,7 +966,7 @@ export const Clangd: Info = {
|
||||
id: "clangd",
|
||||
root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd"]),
|
||||
extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"],
|
||||
async spawn(root) {
|
||||
async spawn(root, _ctx, flags) {
|
||||
const args = ["--background-index", "--clang-tidy"]
|
||||
const fromPath = which("clangd")
|
||||
if (fromPath) {
|
||||
@@ -1002,7 +1001,7 @@ export const Clangd: Info = {
|
||||
}
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
if (flags.disableLspDownload) return
|
||||
log.info("downloading clangd from GitHub releases")
|
||||
|
||||
const releaseResponse = await fetch("https://api.github.com/repos/clangd/clangd/releases/latest")
|
||||
@@ -1113,11 +1112,11 @@ export const Svelte: Info = {
|
||||
id: "svelte",
|
||||
extensions: [".svelte"],
|
||||
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
|
||||
async spawn(root) {
|
||||
async spawn(root, _ctx, flags) {
|
||||
let binary = which("svelteserver")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
if (flags.disableLspDownload) return
|
||||
const resolved = await Npm.which("svelte-language-server")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
@@ -1140,7 +1139,7 @@ export const Astro: Info = {
|
||||
id: "astro",
|
||||
extensions: [".astro"],
|
||||
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
|
||||
async spawn(root, ctx) {
|
||||
async spawn(root, ctx, flags) {
|
||||
const tsserver = Module.resolve("typescript/lib/tsserver.js", ctx.directory)
|
||||
if (!tsserver) {
|
||||
log.info("typescript not found, required for Astro language server")
|
||||
@@ -1151,7 +1150,7 @@ export const Astro: Info = {
|
||||
let binary = which("astro-ls")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
if (flags.disableLspDownload) return
|
||||
const resolved = await Npm.which("@astrojs/language-server")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
@@ -1201,7 +1200,7 @@ export const JDTLS: Info = {
|
||||
if (settingsRoot) return settingsRoot
|
||||
},
|
||||
extensions: [".java"],
|
||||
async spawn(root) {
|
||||
async spawn(root, _ctx, flags) {
|
||||
const java = which("java")
|
||||
if (!java) {
|
||||
log.error("Java 21 or newer is required to run the JDTLS. Please install it first.")
|
||||
@@ -1219,7 +1218,7 @@ export const JDTLS: Info = {
|
||||
const launcherDir = path.join(distPath, "plugins")
|
||||
const installed = await pathExists(launcherDir)
|
||||
if (!installed) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
if (flags.disableLspDownload) return
|
||||
log.info("Downloading JDTLS LSP server.")
|
||||
await fs.mkdir(distPath, { recursive: true })
|
||||
const releaseURL =
|
||||
@@ -1311,13 +1310,13 @@ export const KotlinLS: Info = {
|
||||
// 4) Maven fallback
|
||||
return NearestRoot(["pom.xml"])(file, ctx)
|
||||
},
|
||||
async spawn(root) {
|
||||
async spawn(root, _ctx, flags) {
|
||||
const distPath = path.join(Global.Path.bin, "kotlin-ls")
|
||||
const launcherScript =
|
||||
process.platform === "win32" ? path.join(distPath, "kotlin-lsp.cmd") : path.join(distPath, "kotlin-lsp.sh")
|
||||
const installed = await Filesystem.exists(launcherScript)
|
||||
if (!installed) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
if (flags.disableLspDownload) return
|
||||
log.info("Downloading Kotlin Language Server from GitHub.")
|
||||
|
||||
const releaseResponse = await fetch("https://api.github.com/repos/Kotlin/kotlin-lsp/releases/latest")
|
||||
@@ -1398,11 +1397,11 @@ export const YamlLS: Info = {
|
||||
id: "yaml-ls",
|
||||
extensions: [".yaml", ".yml"],
|
||||
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
|
||||
async spawn(root) {
|
||||
async spawn(root, _ctx, flags) {
|
||||
let binary = which("yaml-language-server")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
if (flags.disableLspDownload) return
|
||||
const resolved = await Npm.which("yaml-language-server")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
@@ -1432,11 +1431,11 @@ export const LuaLS: Info = {
|
||||
"selene.yml",
|
||||
]),
|
||||
extensions: [".lua"],
|
||||
async spawn(root) {
|
||||
async spawn(root, _ctx, flags) {
|
||||
let bin = which("lua-language-server")
|
||||
|
||||
if (!bin) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
if (flags.disableLspDownload) return
|
||||
log.info("downloading lua-language-server from GitHub releases")
|
||||
|
||||
const releaseResponse = await fetch("https://api.github.com/repos/LuaLS/lua-language-server/releases/latest")
|
||||
@@ -1565,11 +1564,11 @@ export const PHPIntelephense: Info = {
|
||||
id: "php intelephense",
|
||||
extensions: [".php"],
|
||||
root: NearestRoot(["composer.json", "composer.lock", ".php-version"]),
|
||||
async spawn(root) {
|
||||
async spawn(root, _ctx, flags) {
|
||||
let binary = which("intelephense")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
if (flags.disableLspDownload) return
|
||||
const resolved = await Npm.which("intelephense")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
@@ -1649,11 +1648,11 @@ export const BashLS: Info = {
|
||||
id: "bash",
|
||||
extensions: [".sh", ".bash", ".zsh", ".ksh"],
|
||||
root: async (_file, ctx) => ctx.directory,
|
||||
async spawn(root) {
|
||||
async spawn(root, _ctx, flags) {
|
||||
let binary = which("bash-language-server")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
if (flags.disableLspDownload) return
|
||||
const resolved = await Npm.which("bash-language-server")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
@@ -1675,11 +1674,11 @@ export const TerraformLS: Info = {
|
||||
id: "terraform",
|
||||
extensions: [".tf", ".tfvars"],
|
||||
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
|
||||
async spawn(root) {
|
||||
async spawn(root, _ctx, flags) {
|
||||
let bin = which("terraform-ls")
|
||||
|
||||
if (!bin) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
if (flags.disableLspDownload) return
|
||||
log.info("downloading terraform-ls from HashiCorp releases")
|
||||
|
||||
const releaseResponse = await fetch("https://api.releases.hashicorp.com/v1/releases/terraform-ls/latest")
|
||||
@@ -1756,11 +1755,11 @@ export const TexLab: Info = {
|
||||
id: "texlab",
|
||||
extensions: [".tex", ".bib"],
|
||||
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
|
||||
async spawn(root) {
|
||||
async spawn(root, _ctx, flags) {
|
||||
let bin = which("texlab")
|
||||
|
||||
if (!bin) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
if (flags.disableLspDownload) return
|
||||
log.info("downloading texlab from GitHub releases")
|
||||
|
||||
const response = await fetch("https://api.github.com/repos/latex-lsp/texlab/releases/latest")
|
||||
@@ -1844,11 +1843,11 @@ export const DockerfileLS: Info = {
|
||||
id: "dockerfile",
|
||||
extensions: [".dockerfile", "Dockerfile"],
|
||||
root: async (_file, ctx) => ctx.directory,
|
||||
async spawn(root) {
|
||||
async spawn(root, _ctx, flags) {
|
||||
let binary = which("docker-langserver")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
if (flags.disableLspDownload) return
|
||||
const resolved = await Npm.which("dockerfile-language-server-nodejs")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
@@ -1940,11 +1939,11 @@ export const Tinymist: Info = {
|
||||
id: "tinymist",
|
||||
extensions: [".typ", ".typc"],
|
||||
root: NearestRoot(["typst.toml"]),
|
||||
async spawn(root) {
|
||||
async spawn(root, _ctx, flags) {
|
||||
let bin = which("tinymist")
|
||||
|
||||
if (!bin) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
if (flags.disableLspDownload) return
|
||||
log.info("downloading tinymist from GitHub releases")
|
||||
|
||||
const response = await fetch("https://api.github.com/repos/Myriad-Dreamin/tinymist/releases/latest")
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { context, type InstanceContext } from "./instance-context"
|
||||
|
||||
export type { InstanceContext } from "./instance-context"
|
||||
|
||||
export const Instance = {
|
||||
get current() {
|
||||
return context.use()
|
||||
},
|
||||
get directory() {
|
||||
return context.use().directory
|
||||
},
|
||||
get worktree() {
|
||||
return context.use().worktree
|
||||
},
|
||||
get project() {
|
||||
return context.use().project
|
||||
},
|
||||
|
||||
/**
|
||||
* Captures the current instance ALS context and returns a wrapper that
|
||||
* restores it when called. Use this for callbacks that fire outside the
|
||||
* instance async context (native addons, event emitters, timers, etc.).
|
||||
*/
|
||||
bind<F extends (...args: any[]) => any>(fn: F): F {
|
||||
const ctx = context.use()
|
||||
return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F
|
||||
},
|
||||
/**
|
||||
* Run a synchronous function within the given instance context ALS.
|
||||
* Use this to bridge from Effect (where InstanceRef carries context)
|
||||
* back to sync code that reads Instance.directory from ALS.
|
||||
*/
|
||||
restore<R>(ctx: InstanceContext, fn: () => R): R {
|
||||
return context.provide(ctx, fn)
|
||||
},
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { context } from "./instance-context"
|
||||
import { InstanceStore } from "./instance-store"
|
||||
|
||||
export async function provide<R>(input: { directory: string; fn: () => R }): Promise<R> {
|
||||
const ctx = await AppRuntime.runPromise(
|
||||
InstanceStore.Service.use((store) => store.load({ directory: input.directory })),
|
||||
)
|
||||
return context.provide(ctx, () => input.fn())
|
||||
}
|
||||
|
||||
export * as WithInstance from "./with-instance"
|
||||
@@ -4,7 +4,6 @@ import type { JSONSchema7 } from "@ai-sdk/provider"
|
||||
import type * as Provider from "./provider"
|
||||
import type * as ModelsDev from "@opencode-ai/core/models"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
|
||||
type Modality = NonNullable<ModelsDev.Model["modalities"]>["input"][number]
|
||||
|
||||
@@ -16,7 +15,7 @@ function mimeToModality(mime: string): Modality | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000
|
||||
export const OUTPUT_TOKEN_MAX = 32_000
|
||||
|
||||
export function sanitizeSurrogates(content: string) {
|
||||
return content.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD")
|
||||
@@ -1251,8 +1250,8 @@ export function providerOptions(model: Provider.Model, options: { [x: string]: a
|
||||
return { [key]: options }
|
||||
}
|
||||
|
||||
export function maxOutputTokens(model: Provider.Model): number {
|
||||
return Math.min(model.limit.output, OUTPUT_TOKEN_MAX) || OUTPUT_TOKEN_MAX
|
||||
export function maxOutputTokens(model: Provider.Model, outputTokenMax = OUTPUT_TOKEN_MAX): number {
|
||||
return Math.min(model.limit.output, outputTokenMax) || outputTokenMax
|
||||
}
|
||||
|
||||
export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import type { InstanceContext } from "@/project/instance"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Effect } from "effect"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { ConfigError } from "@/config/error"
|
||||
import { Cause, Effect } from "effect"
|
||||
import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http"
|
||||
|
||||
@@ -18,6 +19,13 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect)
|
||||
if (!defect) return Effect.failCause(cause)
|
||||
|
||||
const error = defect.defect
|
||||
if (
|
||||
error instanceof NamedError &&
|
||||
(ConfigError.InvalidError.isInstance(error) || ConfigError.JsonError.isInstance(error))
|
||||
) {
|
||||
return Effect.succeed(HttpServerResponse.jsonUnsafe(error.toObject(), { status: 400 }))
|
||||
}
|
||||
|
||||
log.error("failed", { error, cause: Cause.pretty(cause) })
|
||||
|
||||
return Effect.succeed(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
|
||||
@@ -26,9 +26,10 @@ function provideInstanceContext<E>(
|
||||
): Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext> {
|
||||
return Effect.gen(function* () {
|
||||
const route = yield* WorkspaceRouteContext
|
||||
return yield* store.provide(
|
||||
{ directory: decode(route.directory) },
|
||||
effect.pipe(Effect.provideService(WorkspaceRef, route.workspaceID)),
|
||||
const ctx = yield* store.load({ directory: decode(route.directory) })
|
||||
return yield* effect.pipe(
|
||||
Effect.provideService(InstanceRef, ctx),
|
||||
Effect.provideService(WorkspaceRef, route.workspaceID),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
+2
-4
@@ -1,8 +1,7 @@
|
||||
import { getAdapter } from "@/control-plane/adapters"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import type { Target } from "@/control-plane/types"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { WorkspaceAdapterRuntime } from "@/control-plane/workspace-adapter-runtime"
|
||||
import { Session } from "@/session/session"
|
||||
import { HttpApiProxy } from "./proxy"
|
||||
import * as Fence from "@/server/shared/fence"
|
||||
@@ -93,8 +92,7 @@ function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServe
|
||||
}
|
||||
|
||||
function resolveTarget(workspace: Workspace.Info): Effect.Effect<Target> {
|
||||
const adapter = getAdapter(workspace.projectID, workspace.type)
|
||||
return EffectBridge.fromPromise(() => adapter.target(workspace))
|
||||
return WorkspaceAdapterRuntime.target(workspace)
|
||||
}
|
||||
|
||||
function proxyRemote(
|
||||
|
||||
@@ -228,7 +228,12 @@ export const layer = Layer.effect(
|
||||
tokens: MessageV2.Assistant["tokens"]
|
||||
model: Provider.Model
|
||||
}) {
|
||||
return overflow({ cfg: yield* config.get(), tokens: input.tokens, model: input.model })
|
||||
return overflow({
|
||||
cfg: yield* config.get(),
|
||||
tokens: input.tokens,
|
||||
model: input.model,
|
||||
outputTokenMax: flags.outputTokenMax,
|
||||
})
|
||||
})
|
||||
|
||||
const estimate = Effect.fn("SessionCompaction.estimate")(function* (input: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Effect, Layer, Context } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { Config } from "@/config/config"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
@@ -10,9 +11,9 @@ import { Global } from "@opencode-ai/core/global"
|
||||
import type { MessageV2 } from "./message-v2"
|
||||
import type { MessageID } from "./schema"
|
||||
|
||||
const FILES = [
|
||||
const files = (disableClaudeCodePrompt: boolean) => [
|
||||
"AGENTS.md",
|
||||
...(Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT ? [] : ["CLAUDE.md"]),
|
||||
...(disableClaudeCodePrompt ? [] : ["CLAUDE.md"]),
|
||||
"CONTEXT.md", // deprecated
|
||||
]
|
||||
|
||||
@@ -50,18 +51,20 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/In
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
AppFileSystem.Service | Config.Service | Global.Service | HttpClient.HttpClient
|
||||
AppFileSystem.Service | Config.Service | Global.Service | HttpClient.HttpClient | RuntimeFlags.Service
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const cfg = yield* Config.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const global = yield* Global.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
|
||||
const globalFiles = [
|
||||
path.join(global.config, "AGENTS.md"),
|
||||
...(!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT ? [path.join(global.home, ".claude", "CLAUDE.md")] : []),
|
||||
...(!flags.disableClaudeCodePrompt ? [path.join(global.home, ".claude", "CLAUDE.md")] : []),
|
||||
]
|
||||
const instructionFiles = files(flags.disableClaudeCodePrompt)
|
||||
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("Instruction.state")(() =>
|
||||
@@ -117,7 +120,7 @@ export const layer: Layer.Layer<
|
||||
|
||||
// The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor.
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
for (const file of FILES) {
|
||||
for (const file of instructionFiles) {
|
||||
const matches = yield* fs
|
||||
.findUp(file, ctx.directory, ctx.worktree)
|
||||
.pipe(Effect.catch(() => Effect.succeed([])))
|
||||
@@ -165,7 +168,7 @@ export const layer: Layer.Layer<
|
||||
})
|
||||
|
||||
const find = Effect.fn("Instruction.find")(function* (dir: string) {
|
||||
for (const file of FILES) {
|
||||
for (const file of instructionFiles) {
|
||||
const filepath = path.resolve(path.join(dir, file))
|
||||
if (yield* fs.existsSafe(filepath)) return filepath
|
||||
}
|
||||
@@ -225,6 +228,7 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Global.layer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
)
|
||||
|
||||
export function loaded(messages: MessageV2.WithParts[]) {
|
||||
|
||||
@@ -173,7 +173,7 @@ const live: Layer.Layer<
|
||||
: undefined,
|
||||
topP: input.agent.topP ?? ProviderTransform.topP(input.model),
|
||||
topK: ProviderTransform.topK(input.model),
|
||||
maxOutputTokens: ProviderTransform.maxOutputTokens(input.model),
|
||||
maxOutputTokens: ProviderTransform.maxOutputTokens(input.model, flags.outputTokenMax),
|
||||
options,
|
||||
},
|
||||
)
|
||||
@@ -256,7 +256,7 @@ const live: Layer.Layer<
|
||||
|
||||
const bridge = yield* EffectBridge.make()
|
||||
const approvedToolsForSession = new Set<string>()
|
||||
workflowModel.approvalHandler = InstanceState.bind(async (approvalTools) => {
|
||||
workflowModel.approvalHandler = bridge.bind(async (approvalTools) => {
|
||||
const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[]
|
||||
// Auto-approve tools that were already approved in this session
|
||||
// (prevents infinite approval loops for server-side MCP tools)
|
||||
|
||||
@@ -5,18 +5,24 @@ import type { MessageV2 } from "./message-v2"
|
||||
|
||||
const COMPACTION_BUFFER = 20_000
|
||||
|
||||
export function usable(input: { cfg: Config.Info; model: Provider.Model }) {
|
||||
export function usable(input: { cfg: Config.Info; model: Provider.Model; outputTokenMax?: number }) {
|
||||
const context = input.model.limit.context
|
||||
if (context === 0) return 0
|
||||
|
||||
const reserved =
|
||||
input.cfg.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model))
|
||||
input.cfg.compaction?.reserved ??
|
||||
Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model, input.outputTokenMax))
|
||||
return input.model.limit.input
|
||||
? Math.max(0, input.model.limit.input - reserved)
|
||||
: Math.max(0, context - ProviderTransform.maxOutputTokens(input.model))
|
||||
: Math.max(0, context - ProviderTransform.maxOutputTokens(input.model, input.outputTokenMax))
|
||||
}
|
||||
|
||||
export function isOverflow(input: { cfg: Config.Info; tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
|
||||
export function isOverflow(input: {
|
||||
cfg: Config.Info
|
||||
tokens: MessageV2.Assistant["tokens"]
|
||||
model: Provider.Model
|
||||
outputTokenMax?: number
|
||||
}) {
|
||||
if (input.cfg.compaction?.auto === false) return false
|
||||
if (input.model.limit.context === 0) return false
|
||||
|
||||
|
||||
@@ -17,11 +17,6 @@ Only use emojis if the user explicitly requests it. Avoid using emojis in all co
|
||||
IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.
|
||||
IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
|
||||
IMPORTANT: Keep your responses short, since they will be displayed on a command line interface. You MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". Here are some examples to demonstrate appropriate verbosity:
|
||||
<example>
|
||||
user: 2 + 2
|
||||
assistant: 4
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: what is 2+2?
|
||||
assistant: 4
|
||||
@@ -43,11 +38,6 @@ assistant: [use the ls tool to list the files in the current directory, then rea
|
||||
npm run dev
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: How many golf balls fit inside a jetta?
|
||||
assistant: 150000
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: what files are in the directory src/?
|
||||
assistant: [runs ls and sees foo.c, bar.c, baz.c]
|
||||
|
||||
@@ -25,7 +25,7 @@ import { ProjectTable } from "../project/project.sql"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import type { InstanceContext } from "../project/instance"
|
||||
import type { InstanceContext } from "../project/instance-context"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { ProjectID } from "../project/schema"
|
||||
|
||||
@@ -5,7 +5,6 @@ import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import type { Agent } from "@/agent/agent"
|
||||
import { Bus } from "@/bus"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Permission } from "@/permission"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
@@ -166,6 +165,7 @@ const discoverSkills = Effect.fnUntraced(function* (
|
||||
discovery: Discovery.Interface,
|
||||
fsys: AppFileSystem.Interface,
|
||||
global: Global.Interface,
|
||||
disableExternalSkills: boolean,
|
||||
disableClaudeCodeSkills: boolean,
|
||||
directory: string,
|
||||
worktree: string,
|
||||
@@ -173,7 +173,7 @@ const discoverSkills = Effect.fnUntraced(function* (
|
||||
const state: ScanState = { matches: new Set(), dirs: new Set() }
|
||||
|
||||
const externalDirs: string[] = []
|
||||
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
||||
if (!disableExternalSkills) {
|
||||
if (!disableClaudeCodeSkills) externalDirs.push(CLAUDE_EXTERNAL_DIR)
|
||||
externalDirs.push(AGENTS_EXTERNAL_DIR)
|
||||
|
||||
@@ -249,6 +249,7 @@ export const layer = Layer.effect(
|
||||
discovery,
|
||||
fsys,
|
||||
global,
|
||||
flags.disableExternalSkills,
|
||||
flags.disableClaudeCodeSkills,
|
||||
ctx.directory,
|
||||
ctx.worktree,
|
||||
|
||||
@@ -11,7 +11,7 @@ import path from "path"
|
||||
import { readFileSync, readdirSync, existsSync } from "fs"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { InstallationChannel } from "@opencode-ai/core/installation/version"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { init } from "#db"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
@@ -23,19 +23,19 @@ export const NotFoundError = NamedError.create("NotFoundError", {
|
||||
|
||||
const log = Log.create({ service: "db" })
|
||||
|
||||
type ChannelDbFlags = Pick<RuntimeFlags.Info, "disableChannelDb">
|
||||
type DatabaseFlags = Pick<RuntimeFlags.Info, "disableChannelDb" | "skipMigrations">
|
||||
|
||||
const readRuntimeFlags = () =>
|
||||
Effect.runSync(RuntimeFlags.Service.useSync((flags) => flags).pipe(Effect.provide(RuntimeFlags.defaultLayer)))
|
||||
|
||||
export function getChannelPath(flags: ChannelDbFlags = readRuntimeFlags()) {
|
||||
export function getChannelPath(flags: Pick<DatabaseFlags, "disableChannelDb"> = readRuntimeFlags()) {
|
||||
if (["latest", "beta", "prod"].includes(InstallationChannel) || flags.disableChannelDb)
|
||||
return path.join(Global.Path.data, "opencode.db")
|
||||
const safe = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
return path.join(Global.Path.data, `opencode-${safe}.db`)
|
||||
}
|
||||
|
||||
export const getPath = (flags?: ChannelDbFlags) => {
|
||||
export const getPath = (flags?: Pick<DatabaseFlags, "disableChannelDb">) => {
|
||||
if (Flag.OPENCODE_DB) {
|
||||
if (Flag.OPENCODE_DB === ":memory:" || path.isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
|
||||
return path.join(Global.Path.data, Flag.OPENCODE_DB)
|
||||
@@ -93,7 +93,7 @@ let client: Client | undefined
|
||||
let loaded = false
|
||||
|
||||
export const Client = Object.assign(
|
||||
(flags?: ChannelDbFlags): Client => {
|
||||
(flags: DatabaseFlags = readRuntimeFlags()): Client => {
|
||||
if (loaded) return client as Client
|
||||
|
||||
const dbPath = getPath(flags)
|
||||
@@ -118,7 +118,7 @@ export const Client = Object.assign(
|
||||
count: entries.length,
|
||||
mode: typeof OPENCODE_MIGRATIONS !== "undefined" ? "bundled" : "dev",
|
||||
})
|
||||
if (Flag.OPENCODE_SKIP_MIGRATIONS) {
|
||||
if (flags.skipMigrations) {
|
||||
for (const item of entries) {
|
||||
item.sql = "select 1;"
|
||||
}
|
||||
@@ -167,7 +167,7 @@ export function use<T>(callback: (trx: TxOrDb) => T): T {
|
||||
}
|
||||
|
||||
export function effect(fn: () => any | Promise<any>) {
|
||||
const bound = InstanceState.bind(fn)
|
||||
const bound = EffectBridge.bind(fn)
|
||||
try {
|
||||
ctx.use().effects.push(bound)
|
||||
} catch {
|
||||
@@ -188,7 +188,7 @@ export function transaction<T>(
|
||||
} catch (err) {
|
||||
if (err instanceof LocalContext.NotFound) {
|
||||
const effects: (() => void | Promise<void>)[] = []
|
||||
const txCallback = InstanceState.bind((tx: TxOrDb) => ctx.provide({ tx, effects }, () => callback(tx)))
|
||||
const txCallback = EffectBridge.bind((tx: TxOrDb) => ctx.provide({ tx, effects }, () => callback(tx)))
|
||||
const result = Client().transaction(txCallback, { behavior: options?.behavior })
|
||||
for (const effect of effects) effect()
|
||||
return result as NotPromise<T>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { eq } from "drizzle-orm"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Bus as ProjectBus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import type { InstanceContext } from "@/project/instance"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
import { EventSequenceTable, EventTable } from "./event.sql"
|
||||
import type { WorkspaceID } from "@/control-plane/schema"
|
||||
import { EventID } from "./schema"
|
||||
@@ -17,6 +17,7 @@ import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { attachWith } from "@/effect/run-service"
|
||||
|
||||
// Keep `Event["data"]` mutable because projectors mutate the persisted shape
|
||||
// when writing to the database. Bus payloads (`Properties`) stay readonly —
|
||||
@@ -75,6 +76,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Sy
|
||||
export const layer = Layer.effect(Service)(
|
||||
Effect.gen(function* () {
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const bus = yield* ProjectBus.Service
|
||||
|
||||
const replay: Interface["replay"] = Effect.fn("SyncEvent.replay")(function* (event, options) {
|
||||
const def = registry.get(event.type)
|
||||
@@ -112,6 +114,7 @@ export const layer = Layer.effect(Service)(
|
||||
}
|
||||
: undefined
|
||||
process(def, event, {
|
||||
bus,
|
||||
publish,
|
||||
context,
|
||||
ownerID: options?.ownerID,
|
||||
@@ -172,7 +175,7 @@ export const layer = Layer.effect(Service)(
|
||||
const seq = row?.seq != null ? row.seq + 1 : 0
|
||||
|
||||
const event = { id, seq, aggregateID: agg, data }
|
||||
process(def, event, { publish, context, experimentalWorkspaces: flags.experimentalWorkspaces })
|
||||
process(def, event, { bus, publish, context, experimentalWorkspaces: flags.experimentalWorkspaces })
|
||||
},
|
||||
{
|
||||
behavior: "immediate",
|
||||
@@ -209,7 +212,7 @@ export const layer = Layer.effect(Service)(
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(RuntimeFlags.defaultLayer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide([ProjectBus.defaultLayer, RuntimeFlags.defaultLayer]))
|
||||
|
||||
export const use = serviceUse(Service)
|
||||
|
||||
@@ -303,7 +306,13 @@ function register(def: Definition) {
|
||||
function process<Def extends Definition>(
|
||||
def: Def,
|
||||
event: Event<Def>,
|
||||
options: { publish: boolean; context?: PublishContext; ownerID?: string; experimentalWorkspaces: boolean },
|
||||
options: {
|
||||
bus: ProjectBus.Interface
|
||||
publish: boolean
|
||||
context?: PublishContext
|
||||
ownerID?: string
|
||||
experimentalWorkspaces: boolean
|
||||
},
|
||||
) {
|
||||
if (projectors == null) {
|
||||
throw new Error("No projectors available. Call `SyncEvent.init` to install projectors")
|
||||
@@ -348,7 +357,13 @@ function process<Def extends Definition>(
|
||||
}
|
||||
|
||||
const result = convertEvent(def.type, event.data)
|
||||
const publish = (data: unknown) => ProjectBus.publish(def, data as Properties<Def>, { id: event.id })
|
||||
const publish = (data: unknown) =>
|
||||
Effect.runPromise(
|
||||
attachWith(options.bus.publish(def, data as Properties<Def>, { id: event.id }), {
|
||||
instance: options.context?.instance,
|
||||
workspace: options.context?.workspace,
|
||||
}),
|
||||
)
|
||||
if (result instanceof Promise) {
|
||||
void result.then(publish)
|
||||
} else {
|
||||
|
||||
@@ -19,6 +19,8 @@ const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
|
||||
const SAMPLE_BYTES = 4096
|
||||
const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"])
|
||||
|
||||
class ReadStop extends Schema.TaggedErrorClass<ReadStop>()("ReadStop", {}) {}
|
||||
|
||||
// `offset` and `limit` were originally `z.coerce.number()` — the runtime
|
||||
// coercion was useful when the tool was called from a shell but serves no
|
||||
// purpose in the LLM tool-call path (the model emits typed JSON). The JSON
|
||||
@@ -111,15 +113,15 @@ export const ReadTool = Tool.define(
|
||||
// Note: prefer manual TextDecoder over Stream.decodeText — when the source stream
|
||||
// ends without flushing, decodeText drops the final unterminated line. We also
|
||||
// avoid Stream.runForEachWhile (it currently swallows the final unterminated
|
||||
// line of the upstream splitLines pipeline) and instead toggle a `done` flag
|
||||
// and ignore subsequent lines.
|
||||
// line of the upstream splitLines pipeline) and use a tagged error to stop the
|
||||
// upstream file stream as soon as the byte cap is reached.
|
||||
const decoder = new TextDecoder("utf-8")
|
||||
yield* fs.stream(filepath).pipe(
|
||||
Stream.map((bytes) => decoder.decode(bytes, { stream: true })),
|
||||
Stream.splitLines,
|
||||
Stream.runForEach((text) =>
|
||||
Effect.sync(() => {
|
||||
if (flags.done) return
|
||||
Effect.gen(function* () {
|
||||
if (flags.done) return yield* new ReadStop()
|
||||
flags.count += 1
|
||||
if (flags.count <= start) return
|
||||
|
||||
@@ -130,17 +132,19 @@ export const ReadTool = Tool.define(
|
||||
|
||||
const line = text.length > MAX_LINE_LENGTH ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text
|
||||
const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0)
|
||||
if (flags.bytes + size > MAX_BYTES) {
|
||||
flags.cut = true
|
||||
flags.more = true
|
||||
flags.done = true
|
||||
if (flags.bytes + size <= MAX_BYTES) {
|
||||
raw.push(line)
|
||||
flags.bytes += size
|
||||
return
|
||||
}
|
||||
|
||||
raw.push(line)
|
||||
flags.bytes += size
|
||||
flags.cut = true
|
||||
flags.more = true
|
||||
flags.done = true
|
||||
return yield* new ReadStop()
|
||||
}),
|
||||
),
|
||||
Effect.catchTag("ReadStop", () => Effect.void),
|
||||
)
|
||||
|
||||
return { raw, count: flags.count, cut: flags.cut, more: flags.more, offset: opts.offset }
|
||||
|
||||
@@ -417,7 +417,7 @@ function legacyJsonSchema(entries: [string, unknown][]): JSONSchema7 {
|
||||
}
|
||||
|
||||
function zodJsonSchema(schema: z.ZodType): JSONSchema7 {
|
||||
const result = normalizeZodJsonSchema(z.toJSONSchema(schema, { io: "input" }))
|
||||
const result = normalizeZodJsonSchema(z.toJSONSchema(schema, { io: "input", metadata: zodMetadataRegistry(schema) }))
|
||||
if (!isJsonSchemaObject(result)) throw new Error("plugin tool Zod schema produced a non-object JSON Schema")
|
||||
const { $defs, ...rest } = result
|
||||
return (
|
||||
@@ -425,6 +425,32 @@ function zodJsonSchema(schema: z.ZodType): JSONSchema7 {
|
||||
) as JSONSchema7
|
||||
}
|
||||
|
||||
function zodMetadataRegistry(schema: z.ZodType) {
|
||||
const registry = z.registry<Record<string, unknown>>()
|
||||
const seen = new WeakSet<object>()
|
||||
const collect = (value: unknown) => {
|
||||
if (typeof value !== "object" || value === null) return
|
||||
if (seen.has(value)) return
|
||||
seen.add(value)
|
||||
|
||||
if (isZodType(value)) {
|
||||
const metadata = typeof value.meta === "function" ? value.meta() : undefined
|
||||
const description = typeof value.description === "string" ? value.description : undefined
|
||||
const merged = {
|
||||
...(metadata && typeof metadata === "object" ? metadata : {}),
|
||||
...(description ? { description } : {}),
|
||||
}
|
||||
if (Object.keys(merged).length) registry.add(value, merged)
|
||||
collect(value._zod.def)
|
||||
return
|
||||
}
|
||||
|
||||
for (const item of Object.values(value)) collect(item)
|
||||
}
|
||||
collect(schema)
|
||||
return registry
|
||||
}
|
||||
|
||||
function normalizeZodJsonSchema(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map((item) => normalizeZodJsonSchema(item))
|
||||
if (typeof value !== "object" || value === null) return value
|
||||
|
||||
@@ -6,7 +6,7 @@ import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import DESCRIPTION from "./repo_overview.txt"
|
||||
import * as Tool from "./tool"
|
||||
import { parseRepositoryReference, repositoryCachePath } from "@/util/repository"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
repository: Schema.optional(Schema.String).annotate({
|
||||
@@ -108,7 +108,9 @@ export const RepoOverviewTool = Tool.define<typeof Parameters, Metadata, AppFile
|
||||
params: Schema.Schema.Type<typeof Parameters>,
|
||||
) {
|
||||
if (params.path) {
|
||||
const full = path.isAbsolute(params.path) ? params.path : path.resolve(Instance.directory, params.path)
|
||||
const full = path.isAbsolute(params.path)
|
||||
? params.path
|
||||
: path.resolve(yield* InstanceState.directory, params.path)
|
||||
return { path: full, repository: params.repository }
|
||||
}
|
||||
|
||||
|
||||
@@ -609,10 +609,10 @@ export const ShellTool = Tool.define(
|
||||
parameters: prompt.parameters,
|
||||
execute: (params: Parameters, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const executeInstance = yield* InstanceState.context
|
||||
const instanceCtx = yield* InstanceState.context
|
||||
const cwd = params.workdir
|
||||
? yield* resolvePath(params.workdir, executeInstance.directory, shell)
|
||||
: executeInstance.directory
|
||||
? yield* resolvePath(params.workdir, instanceCtx.directory, shell)
|
||||
: instanceCtx.directory
|
||||
if (params.timeout !== undefined && params.timeout < 0) {
|
||||
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
|
||||
}
|
||||
@@ -623,8 +623,8 @@ export const ShellTool = Tool.define(
|
||||
const tree = yield* Effect.acquireRelease(parse(params.command, ps), (tree) =>
|
||||
Effect.sync(() => tree.delete()),
|
||||
)
|
||||
const scan = yield* collect(tree.rootNode, cwd, ps, shell, executeInstance)
|
||||
if (!containsPath(cwd, executeInstance)) scan.dirs.add(cwd)
|
||||
const scan = yield* collect(tree.rootNode, cwd, ps, shell, instanceCtx)
|
||||
if (!containsPath(cwd, instanceCtx)) scan.dirs.add(cwd)
|
||||
yield* ask(ctx, scan)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -10,68 +10,12 @@ IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO N
|
||||
|
||||
${commandSection}
|
||||
|
||||
# Committing changes with git
|
||||
|
||||
Only create commits when requested by the user. If unclear, ask first. When the user asks you to create a new git commit, follow these steps carefully:
|
||||
|
||||
Git Safety Protocol:
|
||||
- NEVER update the git config
|
||||
- NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them
|
||||
- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it
|
||||
- NEVER run force push to main/master, warn the user if they request it
|
||||
- Avoid git commit --amend. ONLY use --amend when ALL conditions are met:
|
||||
(1) User explicitly requested amend, OR the commit succeeded and pre-commit hooks auto-modified files that need including — verify by checking `git log` that HEAD is the new commit before amending
|
||||
(2) HEAD commit was created by you in this conversation (verify: git log -1 --format='%an %ae')
|
||||
(3) Commit has NOT been pushed to remote (verify: git status shows "Your branch is ahead")
|
||||
- CRITICAL: If commit FAILED or was REJECTED by hook, NEVER amend - fix the issue and create a NEW commit
|
||||
- CRITICAL: If you already pushed to remote, NEVER amend unless user explicitly requests it (requires force push)
|
||||
- NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
|
||||
|
||||
1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following ${gitCommands} in parallel, each using the ${toolName} tool:
|
||||
- Run a git status command to see all untracked files.
|
||||
- Run a git diff command to see both staged and unstaged changes that will be committed.
|
||||
- Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.
|
||||
2. Analyze all staged changes (both previously staged and newly added) and draft a commit message:
|
||||
- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.).
|
||||
- Do not commit files that likely contain secrets (.env, credentials.json, etc.). Warn the user if they specifically request to commit those files
|
||||
- Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what"
|
||||
- Ensure it accurately reflects the changes and their purpose
|
||||
3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands:
|
||||
- Add relevant untracked files to the staging area.
|
||||
- Create the commit with a message
|
||||
- Run git status after the commit completes to verify success.
|
||||
Note: git status depends on the commit completing, so run it sequentially after the commit.
|
||||
4. If the commit fails due to pre-commit hook, fix the issue and create a NEW commit (see amend rules above)
|
||||
|
||||
Important notes:
|
||||
- NEVER run additional commands to read or explore code, besides ${gitCommandRestriction}
|
||||
- NEVER use the TodoWrite or Task tools
|
||||
- DO NOT push to the remote repository unless the user explicitly asks you to do so
|
||||
- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.
|
||||
- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit
|
||||
|
||||
# Creating pull requests
|
||||
Use the gh command via the ${toolName} tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed.
|
||||
|
||||
IMPORTANT: When the user asks you to create a pull request, follow these steps carefully:
|
||||
|
||||
1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following ${gitCommands} in parallel using the ${toolName} tool, in order to understand the current state of the branch since it diverged from the main branch:
|
||||
- Run a git status command to see all untracked files
|
||||
- Run a git diff command to see both staged and unstaged changes that will be committed
|
||||
- Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote
|
||||
- Run a git log command and `git diff [base-branch]...HEAD` to understand the full commit history for the current branch (from the time it diverged from the base branch)
|
||||
2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request summary
|
||||
3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel:
|
||||
- Create new branch if needed
|
||||
- Push to remote with -u flag if needed
|
||||
- ${createPrInstruction}
|
||||
<example>
|
||||
${createPrExample}
|
||||
</example>
|
||||
|
||||
Important:
|
||||
- DO NOT use the TodoWrite or Task tools
|
||||
- Return the PR URL when you're done, so the user can see it
|
||||
|
||||
# Other common operations
|
||||
- View comments on a GitHub PR: gh api repos/foo/bar/pulls/123/comments
|
||||
# Git and GitHub
|
||||
- Only commit, amend, push, or create PRs when explicitly requested.
|
||||
- Before committing, inspect `git status`, `git diff`, and `git log --oneline -10`; stage only intended files and never commit secrets.
|
||||
- Write a concise commit message that matches the repo style.
|
||||
- Do not update git config, skip hooks, use interactive `-i`, force-push, or create empty commits unless explicitly requested.
|
||||
- If a commit fails or hooks reject it, fix the issue and create a new commit; do not amend the failed commit.
|
||||
- Before creating a PR, inspect status, diff, remote tracking, recent commits, and the diff from the base branch.
|
||||
- Review all commits included in the PR, not just the latest commit.
|
||||
- Use `gh` for GitHub tasks, including PRs, issues, checks, and releases; return the PR URL when done.
|
||||
|
||||
@@ -2,14 +2,11 @@ Launch a new agent to handle complex, multistep tasks autonomously.
|
||||
|
||||
When using the Task tool, you must specify a subagent_type parameter to select which agent type to use.
|
||||
|
||||
When to use the Task tool:
|
||||
- When you are instructed to execute custom slash commands. Use the Task tool with the slash command invocation as the entire prompt. The slash command can take arguments. For example: Task(description="Check the file", prompt="/check-file path/to/file.py")
|
||||
|
||||
When NOT to use the Task tool:
|
||||
- If you want to read a specific file path, use the Read or Glob tool instead of the Task tool, to find the match more quickly
|
||||
- If you are searching for a specific class definition like "class Foo", use the Glob tool instead, to find the match more quickly
|
||||
- If you are searching for a specific class definition like "class Foo", use the Grep tool instead, to find the match more quickly
|
||||
- If you are searching for code within a specific file or set of 2-3 files, use the Read tool instead of the Task tool, to find the match more quickly
|
||||
- Other tasks that are not related to the agent descriptions above
|
||||
- If no available agent is a good fit for the task, use other tools directly
|
||||
|
||||
|
||||
Usage notes:
|
||||
@@ -20,39 +17,3 @@ Usage notes:
|
||||
5. The agent's outputs should generally be trusted
|
||||
6. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent. Tell it how to verify its work if possible (e.g., relevant test commands).
|
||||
7. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.
|
||||
|
||||
Example usage (NOTE: The agents below are fictional examples for illustration only - use the actual agents listed above):
|
||||
|
||||
<example_agent_descriptions>
|
||||
"code-reviewer": use this agent after you are done writing a significant piece of code
|
||||
"greeting-responder": use this agent when to respond to user greetings with a friendly joke
|
||||
</example_agent_description>
|
||||
|
||||
<example>
|
||||
user: "Please write a function that checks if a number is prime"
|
||||
assistant: Sure let me write a function that checks if a number is prime
|
||||
assistant: First let me use the Write tool to write a function that checks if a number is prime
|
||||
assistant: I'm going to use the Write tool to write the following code:
|
||||
<code>
|
||||
function isPrime(n) {
|
||||
if (n <= 1) return false
|
||||
for (let i = 2; i * i <= n; i++) {
|
||||
if (n % i === 0) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
</code>
|
||||
<commentary>
|
||||
Since a significant piece of code was written and the task was completed, now use the code-reviewer agent to review the code
|
||||
</commentary>
|
||||
assistant: Now let me use the code-reviewer agent to review the code
|
||||
assistant: Uses the Task tool to launch the code-reviewer agent
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "Hello"
|
||||
<commentary>
|
||||
Since the user is greeting, use the greeting-responder agent to respond with a friendly joke
|
||||
</commentary>
|
||||
assistant: "I'm going to use the Task tool to launch the with the greeting-responder agent"
|
||||
</example>
|
||||
|
||||
@@ -1,167 +1,44 @@
|
||||
Use this tool to create and manage a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user.
|
||||
It also helps the user understand the progress of the task and overall progress of their requests.
|
||||
Create and maintain a structured task list for the current coding session. Tracks progress, organizes multi-step work, and surfaces status to the user.
|
||||
|
||||
## When to Use This Tool
|
||||
Use this tool proactively in these scenarios:
|
||||
## When to use
|
||||
Use proactively when:
|
||||
- The task requires 3+ distinct steps or actions (not just 3 tool calls for a single conceptual step)
|
||||
- The work is non-trivial and benefits from planning
|
||||
- The user provides multiple tasks (numbered or comma-separated) or explicitly asks for a todo list
|
||||
- New instructions arrive - capture them as todos
|
||||
- You start a task - mark it `in_progress` (only one at a time) before working
|
||||
- You finish a task - mark it `completed` and add any follow-ups discovered during the work
|
||||
|
||||
1. Complex multistep tasks - When a task requires 3 or more distinct steps or actions
|
||||
2. Non-trivial and complex tasks - Tasks that require careful planning or multiple operations
|
||||
3. User explicitly requests todo list - When the user directly asks you to use the todo list
|
||||
4. User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated)
|
||||
5. After receiving new instructions - Immediately capture user requirements as todos. Feel free to edit the todo list based on new information.
|
||||
6. After completing a task - Mark it complete and add any new follow-up tasks
|
||||
7. When you start working on a new task, mark the todo as in_progress. Ideally you should only have one todo as in_progress at a time. Complete existing tasks before starting new ones.
|
||||
## When NOT to use
|
||||
Skip when:
|
||||
- The work is a single, straightforward task (or <3 trivial steps)
|
||||
- The request is purely informational or conversational
|
||||
- Tracking adds no organizational value
|
||||
|
||||
## When NOT to Use This Tool
|
||||
## States
|
||||
- `pending` - not started
|
||||
- `in_progress` - actively working (exactly ONE at a time)
|
||||
- `completed` - finished successfully
|
||||
- `cancelled` - no longer needed
|
||||
|
||||
Skip using this tool when:
|
||||
1. There is only a single, straightforward task
|
||||
2. The task is trivial and tracking it provides no organizational benefit
|
||||
3. The task can be completed in less than 3 trivial steps
|
||||
4. The task is purely conversational or informational
|
||||
## Rules
|
||||
- Update status in real time; don't batch completions
|
||||
- Mark `completed` only after the required work is actually done, including any required verification. Never based on intent.
|
||||
- Keep exactly one `in_progress` while work remains
|
||||
- If blocked or partial, keep it `in_progress` and add a follow-up todo describing the blocker
|
||||
- Preserve user-provided commands verbatim (flags, args, order)
|
||||
- Items should be specific and actionable; break large work into smaller steps
|
||||
|
||||
NOTE that you should not use this tool if there is only one trivial task to do. In this case you are better off just doing the task directly.
|
||||
## Examples
|
||||
|
||||
## Examples of When to Use the Todo List
|
||||
Use it:
|
||||
- "Add a dark mode toggle and run the tests" -> multi-step feature + explicit verification
|
||||
- "Rename getCwd -> getCurrentWorkingDirectory across the repo" -> grep reveals 15 occurrences in 8 files
|
||||
- "Implement registration, catalog, cart, checkout" -> multiple complex features
|
||||
|
||||
<example>
|
||||
User: I want to add a dark mode toggle to the application settings. Make sure you run the tests and build when you're done!
|
||||
Assistant: I'll help add a dark mode toggle to your application settings. Let me create a todo list to track this implementation.
|
||||
*Creates todo list with the following items:*
|
||||
1. Create dark mode toggle component in Settings page
|
||||
2. Add dark mode state management (context/store)
|
||||
3. Implement CSS-in-JS styles for dark theme
|
||||
4. Update existing components to support theme switching
|
||||
5. Run tests and build process, addressing any failures or errors that occur
|
||||
*Begins working on the first task*
|
||||
|
||||
<reasoning>
|
||||
The assistant used the todo list because:
|
||||
1. Adding dark mode is a multi-step feature requiring UI, state management, and styling changes
|
||||
2. The user explicitly requested tests and build be run afterward
|
||||
3. The assistant inferred that tests and build need to pass by adding "Ensure tests and build succeed" as the final task
|
||||
</reasoning>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
User: Help me rename the function getCwd to getCurrentWorkingDirectory across my project
|
||||
Assistant: Let me first search through your codebase to find all occurrences of 'getCwd'.
|
||||
*Uses grep or search tools to locate all instances of getCwd in the codebase*
|
||||
Assistant: I've found 15 instances of 'getCwd' across 8 different files. Let me create a todo list to track these changes.
|
||||
*Creates todo list with specific items for each file that needs updating*
|
||||
|
||||
<reasoning>
|
||||
The assistant used the todo list because:
|
||||
1. First, the assistant searched to understand the scope of the task
|
||||
2. Upon finding multiple occurrences across different files, it determined this was a complex task with multiple steps
|
||||
3. The todo list helps ensure every instance is tracked and updated systematically
|
||||
4. This approach prevents missing any occurrences and maintains code consistency
|
||||
</reasoning>
|
||||
</example>
|
||||
|
||||
|
||||
<example>
|
||||
User: I need to implement these features for my e-commerce site: user registration, product catalog, shopping cart, and checkout flow.
|
||||
Assistant: I'll help implement these features. First, let's add all the features to the todo list.
|
||||
*Creates a todo list breaking down each feature into specific tasks based on the project architecture*
|
||||
Assistant: Let's start with user registration. This will involve creating database models, API endpoints, and frontend forms that integrate with your existing authentication system.
|
||||
|
||||
<reasoning>
|
||||
The assistant used the todo list because:
|
||||
1. The user provided multiple complex features to implement in a comma separated list
|
||||
2. The todo list helps organize these large features into manageable tasks
|
||||
3. This approach allows for tracking progress across the entire implementation
|
||||
</reasoning>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
User: Can you help optimize my React application? It's rendering slowly and has performance issues.</user>
|
||||
Assistant: I'll help optimize your React application. First, let me examine your codebase to identify potential performance bottlenecks.
|
||||
*Reviews component structure, render patterns, state management, and data fetching*
|
||||
Assistant: After analyzing your codebase, I've identified several performance issues. Let me create a todo list to track our optimization efforts.
|
||||
*Creates todo list with items like: 1) Implement memoization for expensive calculations in ProductList, 2) Add virtualization for long lists in Dashboard, 3) Optimize image loading in Gallery component, 4) Fix state update loops in ShoppingCart, 5) Review bundle size and implement code splitting*
|
||||
Let's start by implementing memoization for the expensive calculations in your ProductList component.</assistant>
|
||||
|
||||
<reasoning>
|
||||
The assistant used the todo list because:
|
||||
1. First, the assistant examined the codebase to identify specific performance issues
|
||||
2. Based on this analysis, it identified multiple optimization opportunities
|
||||
3. Performance optimization is a non-trivial task requiring multiple steps
|
||||
4. The todo list helps methodically track improvements across different components
|
||||
5. This systematic approach ensures all performance bottlenecks are addressed
|
||||
</reasoning>
|
||||
</example>
|
||||
|
||||
## Examples of When NOT to Use the Todo List
|
||||
|
||||
<example>
|
||||
User: How do I print 'Hello World' in Python?
|
||||
Assistant: In Python, you can print "Hello World" with this simple code:
|
||||
|
||||
python
|
||||
print("Hello World")
|
||||
|
||||
This will output the text "Hello World" to the console when executed.</assistant>
|
||||
|
||||
<reasoning>
|
||||
The assistant did not use the todo list because this is a single, trivial task that can be completed in one step. There's no need to track multiple tasks or steps for such a straightforward request.
|
||||
</reasoning>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
User: What does the git status command do?
|
||||
Assistant: The git status command shows the current state of your working directory and staging area. It displays which changes have been staged, which haven't, and which files aren't being tracked by Git. This is a fundamental command for understanding the current state of your repository before making commits.
|
||||
|
||||
<reasoning>
|
||||
The assistant did not use the todo list because this is an informational request with no actual coding task to complete. The user is simply asking for an explanation, not for the assistant to perform multiple steps or tasks.
|
||||
</reasoning>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
User: Can you add a comment to the calculateTotal function to explain what it does?
|
||||
Assistant: Sure, let me add a comment to the calculateTotal function to explain what it does.
|
||||
* Uses the Edit tool to add a comment to the calculateTotal function *
|
||||
|
||||
<reasoning>
|
||||
The assistant did not use the todo list because this is a single, straightforward task confined to one location in the code. Adding a comment doesn't require tracking multiple steps or systematic organization.
|
||||
</reasoning>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
User: Run npm install for me and tell me what happens.
|
||||
Assistant: I'll run the npm install command for you.
|
||||
|
||||
*Executes: npm install*
|
||||
|
||||
The command completed successfully. Here's the output:
|
||||
[Output of npm install command]
|
||||
|
||||
All dependencies have been installed according to your package.json file.
|
||||
|
||||
<reasoning>
|
||||
The assistant did not use the todo list because this is a single command execution with immediate results. There are no multiple steps to track or organize, making the todo list unnecessary for this straightforward task.
|
||||
</reasoning>
|
||||
</example>
|
||||
|
||||
## Task States and Management
|
||||
|
||||
1. **Task States**: Use these states to track progress:
|
||||
- pending: Task not yet started
|
||||
- in_progress: Currently working on (limit to ONE task at a time)
|
||||
- completed: Task finished successfully
|
||||
- cancelled: Task no longer needed
|
||||
|
||||
2. **Task Management**:
|
||||
- Update task status in real-time as you work
|
||||
- Mark tasks complete IMMEDIATELY after finishing (don't batch completions)
|
||||
- Only have ONE task in_progress at any time
|
||||
- Complete current tasks before starting new ones
|
||||
- Cancel tasks that become irrelevant
|
||||
|
||||
3. **Task Breakdown**:
|
||||
- Create specific, actionable items
|
||||
- Break complex tasks into smaller, manageable steps
|
||||
- Use clear, descriptive task names
|
||||
|
||||
When in doubt, use this tool. Being proactive with task management demonstrates attentiveness and ensures you complete all requirements successfully.
|
||||
Skip it:
|
||||
- "How do I print Hello World in Python?" -> informational
|
||||
- "Add a comment to calculateTotal" -> single edit
|
||||
- "Run npm install and tell me what happened" -> one command
|
||||
|
||||
When in doubt, use it.
|
||||
|
||||
@@ -116,7 +116,7 @@ describe("my service", () => {
|
||||
Prefer the Effect-aware helpers from `fixture/fixture.ts` instead of building a manual runtime in each test.
|
||||
|
||||
- `tmpdirScoped(options?)` creates a scoped temp directory and cleans it up when the Effect scope closes.
|
||||
- `provideInstance(dir)(effect)` is the low-level helper. It does not create a directory; it just runs an Effect with `Instance.current` bound to `dir`.
|
||||
- `provideInstance(dir)(effect)` is the low-level helper. It does not create a directory; it runs an Effect with `InstanceRef` provided for `dir`.
|
||||
- `provideTmpdirInstance((dir) => effect, options?)` is the convenience helper. It creates a temp directory, binds it as the active instance, and disposes the instance on cleanup.
|
||||
- `provideTmpdirServer((input) => effect, options?)` does the same, but also provides the test LLM server.
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ Do not maintain a long file checklist here. It goes stale quickly.
|
||||
When looking for the next target, search for current anti-patterns:
|
||||
|
||||
```bash
|
||||
git grep -n "Effect.runPromise\|ManagedRuntime\|Promise.withResolvers\|Bun.sleep\|WithInstance" -- packages/opencode/test
|
||||
git grep -n "Effect.runPromise\|ManagedRuntime\|Promise.withResolvers\|Bun.sleep\|withTestInstance" -- packages/opencode/test
|
||||
```
|
||||
|
||||
Then choose one file or one small cluster, keep the PR focused, and mention
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { aggregateFailures } from "@/cli/cmd/tui/context/aggregate-failures"
|
||||
import { ConfigError } from "@/config/error"
|
||||
|
||||
describe("aggregateFailures", () => {
|
||||
test("returns null when every result is fulfilled", () => {
|
||||
@@ -41,11 +42,48 @@ describe("aggregateFailures", () => {
|
||||
expect(err!.message).toContain("agents: boom")
|
||||
})
|
||||
|
||||
test("formats structured config errors hidden inside SDK error causes", () => {
|
||||
const configError = new ConfigError.InvalidError({
|
||||
path: "/tmp/opencode.json",
|
||||
issues: [{ message: "Expected object", path: ["provider", "anthropic", "options"] }],
|
||||
})
|
||||
const err = aggregateFailures([
|
||||
{
|
||||
name: "config.get",
|
||||
result: {
|
||||
status: "rejected",
|
||||
reason: new Error("ConfigInvalidError", {
|
||||
cause: {
|
||||
body: configError.toObject(),
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
expect(err!.message).toContain("config.get: Configuration is invalid at /tmp/opencode.json")
|
||||
expect(err!.message).toContain("Expected object provider.anthropic.options")
|
||||
})
|
||||
|
||||
test("deduplicates identical failure messages across startup requests", () => {
|
||||
const reason = new Error("same config problem")
|
||||
const err = aggregateFailures([
|
||||
{ name: "config.providers", result: { status: "rejected", reason } },
|
||||
{ name: "provider.list", result: { status: "rejected", reason } },
|
||||
{ name: "app.agents", result: { status: "rejected", reason } },
|
||||
{ name: "config.get", result: { status: "rejected", reason } },
|
||||
{ name: "project.sync", result: { status: "fulfilled", value: undefined } },
|
||||
])
|
||||
|
||||
expect(err!.message).toContain("4 of 5 requests failed: same config problem")
|
||||
expect(err!.message).toContain("Affected startup requests: config.providers, provider.list, app.agents, config.get")
|
||||
expect(err!.message.match(/same config problem/g)?.length).toBe(1)
|
||||
})
|
||||
|
||||
test("attaches structured failure list under .cause", () => {
|
||||
const reason = new Error("nope")
|
||||
const err = aggregateFailures([{ name: "providers", result: { status: "rejected", reason } }])
|
||||
const cause = err!.cause as { failures: Array<{ name: string; reason: unknown }> }
|
||||
expect(cause.failures).toEqual([{ name: "providers", reason }])
|
||||
expect(err!.cause).toEqual({ failures: [{ name: "providers", reason }] })
|
||||
})
|
||||
|
||||
test("falls back to String() for opaque reasons", () => {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { isDuplicateEntry, type PromptInfo } from "../../../../src/cli/cmd/tui/component/prompt/history"
|
||||
|
||||
const entry = (input: string, parts: PromptInfo["parts"] = []): PromptInfo => ({ input, parts })
|
||||
|
||||
describe("prompt history dedupe", () => {
|
||||
test("returns false when there is no previous entry", () => {
|
||||
expect(isDuplicateEntry(undefined, entry("hello"))).toBe(false)
|
||||
})
|
||||
|
||||
test("dedupes identical consecutive entries", () => {
|
||||
const a = entry("hello world this is over twenty chars")
|
||||
const b = entry("hello world this is over twenty chars")
|
||||
expect(isDuplicateEntry(a, b)).toBe(true)
|
||||
})
|
||||
|
||||
test("does not dedupe when input text differs", () => {
|
||||
expect(isDuplicateEntry(entry("foo"), entry("bar"))).toBe(false)
|
||||
})
|
||||
|
||||
test("does not dedupe when parts differ", () => {
|
||||
const a = entry("describe this", [
|
||||
{
|
||||
type: "file",
|
||||
mime: "image/png",
|
||||
filename: "a.png",
|
||||
url: "data:image/png;base64,AAA",
|
||||
},
|
||||
])
|
||||
const b = entry("describe this", [
|
||||
{
|
||||
type: "file",
|
||||
mime: "image/png",
|
||||
filename: "b.png",
|
||||
url: "data:image/png;base64,BBB",
|
||||
},
|
||||
])
|
||||
expect(isDuplicateEntry(a, b)).toBe(false)
|
||||
})
|
||||
|
||||
test("does not dedupe when mode differs", () => {
|
||||
expect(isDuplicateEntry({ ...entry("ls"), mode: "normal" }, { ...entry("ls"), mode: "shell" })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,6 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Effect } from "effect"
|
||||
import { fileURLToPath } from "url"
|
||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
@@ -13,46 +12,28 @@ afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
// Regression for PR #25522: when an effectCmd handler does
|
||||
// `yield* Effect.promise(async () => { ... await runPromise(svcMethod) ... })`,
|
||||
// the inner runPromise creates a fresh fiber after `await` whose Effect context
|
||||
// has lost the outer InstanceRef. Services that read `InstanceState.context`
|
||||
// then fall back to `Instance.current` ALS, which must be installed at the JS
|
||||
// callback boundary (Node ALS persists across awaits, Effect's fiber context
|
||||
// does not). `it.instance` provides the loaded InstanceRef; the explicit
|
||||
// Instance.restore mirrors effectCmd's load + ALS-restore wrap.
|
||||
// Pins effect-cmd.ts directly: the pattern test below exercises the load +
|
||||
// Instance.restore boundary via the shared `it.instance` fixture,
|
||||
// so a regression that removed `Instance.restore` from effect-cmd.ts wouldn't
|
||||
// fail it. This grep guards the actual production callsite.
|
||||
it.live("effect-cmd.ts wraps the handler body in Instance.restore", () =>
|
||||
it.live("effect-cmd.ts does not restore legacy instance ALS", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const source = yield* fs.readFileString(fileURLToPath(new URL("../../src/cli/effect-cmd.ts", import.meta.url)))
|
||||
expect(source).toContain("Instance.restore(ctx")
|
||||
expect(source).not.toContain("restore(ctx")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"Instance.current reachable after await inside restored Effect.promise(async)",
|
||||
"InstanceRef remains the handler context across Effect promise awaits",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) throw new Error("InstanceRef not provided")
|
||||
|
||||
const current = yield* Effect.promise(() =>
|
||||
Instance.restore(ctx, async () => {
|
||||
await Promise.resolve()
|
||||
try {
|
||||
return Instance.current
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}),
|
||||
)
|
||||
const directory = yield* Effect.promise(async () => {
|
||||
await Promise.resolve()
|
||||
return ctx.directory
|
||||
})
|
||||
|
||||
expect(current?.directory).toBe(test.directory)
|
||||
expect(directory).toBe(test.directory)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user