Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8157edeaa1 | ||
|
|
c4da7b5dc6 | ||
|
|
89c51a86bd |
@@ -1,50 +0,0 @@
|
||||
name: close-prs
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 22 * * *" # Daily at 10:00 PM UTC
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry-run:
|
||||
description: "Log matching PRs without closing them"
|
||||
type: boolean
|
||||
default: true
|
||||
max-close:
|
||||
description: "Maximum matching PRs to close"
|
||||
type: string
|
||||
required: false
|
||||
default: "50"
|
||||
|
||||
jobs:
|
||||
close:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 240
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Close old PRs without enough positive reactions
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
max_close="${{ inputs['max-close'] }}"
|
||||
if [ -z "$max_close" ]; then
|
||||
max_close="50"
|
||||
fi
|
||||
|
||||
args=("--threshold" "2" "--age-months" "1" "--sleep-ms" "20000" "--max-close" "$max_close")
|
||||
|
||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
args+=("--execute")
|
||||
elif [ "${{ inputs['dry-run'] }}" = "false" ]; then
|
||||
args+=("--execute")
|
||||
fi
|
||||
|
||||
bun script/github/close-prs.ts "${args[@]}"
|
||||
@@ -0,0 +1,235 @@
|
||||
name: close-stale-prs
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dryRun:
|
||||
description: "Log actions without closing PRs"
|
||||
type: boolean
|
||||
default: false
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
close-stale-prs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Close inactive PRs
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const DAYS_INACTIVE = 60
|
||||
const MAX_RETRIES = 3
|
||||
|
||||
// Adaptive delay: fast for small batches, slower for large to respect
|
||||
// GitHub's 80 content-generating requests/minute limit
|
||||
const SMALL_BATCH_THRESHOLD = 10
|
||||
const SMALL_BATCH_DELAY_MS = 1000 // 1s for daily operations (≤10 PRs)
|
||||
const LARGE_BATCH_DELAY_MS = 2000 // 2s for backlog (>10 PRs) = ~30 ops/min, well under 80 limit
|
||||
|
||||
const startTime = Date.now()
|
||||
const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000)
|
||||
const { owner, repo } = context.repo
|
||||
const dryRun = context.payload.inputs?.dryRun === "true"
|
||||
|
||||
core.info(`Dry run mode: ${dryRun}`)
|
||||
core.info(`Cutoff date: ${cutoff.toISOString()}`)
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function withRetry(fn, description = 'API call') {
|
||||
let lastError
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const result = await fn()
|
||||
return result
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
const isRateLimited = error.status === 403 &&
|
||||
(error.message?.includes('rate limit') || error.message?.includes('secondary'))
|
||||
|
||||
if (!isRateLimited) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Parse retry-after header, default to 60 seconds
|
||||
const retryAfter = error.response?.headers?.['retry-after']
|
||||
? parseInt(error.response.headers['retry-after'])
|
||||
: 60
|
||||
|
||||
// Exponential backoff: retryAfter * 2^attempt
|
||||
const backoffMs = retryAfter * 1000 * Math.pow(2, attempt)
|
||||
|
||||
core.warning(`${description}: Rate limited (attempt ${attempt + 1}/${MAX_RETRIES}). Waiting ${backoffMs / 1000}s before retry...`)
|
||||
|
||||
await sleep(backoffMs)
|
||||
}
|
||||
}
|
||||
core.error(`${description}: Max retries (${MAX_RETRIES}) exceeded`)
|
||||
throw lastError
|
||||
}
|
||||
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!, $cursor: String) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequests(first: 100, states: OPEN, after: $cursor) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
nodes {
|
||||
number
|
||||
title
|
||||
author {
|
||||
login
|
||||
}
|
||||
createdAt
|
||||
commits(last: 1) {
|
||||
nodes {
|
||||
commit {
|
||||
committedDate
|
||||
}
|
||||
}
|
||||
}
|
||||
comments(last: 1) {
|
||||
nodes {
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
reviews(last: 1) {
|
||||
nodes {
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const allPrs = []
|
||||
let cursor = null
|
||||
let hasNextPage = true
|
||||
let pageCount = 0
|
||||
|
||||
while (hasNextPage) {
|
||||
pageCount++
|
||||
core.info(`Fetching page ${pageCount} of open PRs...`)
|
||||
|
||||
const result = await withRetry(
|
||||
() => github.graphql(query, { owner, repo, cursor }),
|
||||
`GraphQL page ${pageCount}`
|
||||
)
|
||||
|
||||
allPrs.push(...result.repository.pullRequests.nodes)
|
||||
hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage
|
||||
cursor = result.repository.pullRequests.pageInfo.endCursor
|
||||
|
||||
core.info(`Page ${pageCount}: fetched ${result.repository.pullRequests.nodes.length} PRs (total: ${allPrs.length})`)
|
||||
|
||||
// Delay between pagination requests (use small batch delay for reads)
|
||||
if (hasNextPage) {
|
||||
await sleep(SMALL_BATCH_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
core.info(`Found ${allPrs.length} open pull requests`)
|
||||
|
||||
const stalePrs = allPrs.filter((pr) => {
|
||||
const dates = [
|
||||
new Date(pr.createdAt),
|
||||
pr.commits.nodes[0] ? new Date(pr.commits.nodes[0].commit.committedDate) : null,
|
||||
pr.comments.nodes[0] ? new Date(pr.comments.nodes[0].createdAt) : null,
|
||||
pr.reviews.nodes[0] ? new Date(pr.reviews.nodes[0].createdAt) : null,
|
||||
].filter((d) => d !== null)
|
||||
|
||||
const lastActivity = dates.sort((a, b) => b.getTime() - a.getTime())[0]
|
||||
|
||||
if (!lastActivity || lastActivity > cutoff) {
|
||||
core.info(`PR #${pr.number} is fresh (last activity: ${lastActivity?.toISOString() || "unknown"})`)
|
||||
return false
|
||||
}
|
||||
|
||||
core.info(`PR #${pr.number} is STALE (last activity: ${lastActivity.toISOString()})`)
|
||||
return true
|
||||
})
|
||||
|
||||
if (!stalePrs.length) {
|
||||
core.info("No stale pull requests found.")
|
||||
return
|
||||
}
|
||||
|
||||
core.info(`Found ${stalePrs.length} stale pull requests`)
|
||||
|
||||
// ============================================
|
||||
// Close stale PRs
|
||||
// ============================================
|
||||
const requestDelayMs = stalePrs.length > SMALL_BATCH_THRESHOLD
|
||||
? LARGE_BATCH_DELAY_MS
|
||||
: SMALL_BATCH_DELAY_MS
|
||||
|
||||
core.info(`Using ${requestDelayMs}ms delay between operations (${stalePrs.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`)
|
||||
|
||||
let closedCount = 0
|
||||
let skippedCount = 0
|
||||
|
||||
for (const pr of stalePrs) {
|
||||
const issue_number = pr.number
|
||||
const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.`
|
||||
|
||||
if (dryRun) {
|
||||
core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
// Add comment
|
||||
await withRetry(
|
||||
() => github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body: closeComment,
|
||||
}),
|
||||
`Comment on PR #${issue_number}`
|
||||
)
|
||||
|
||||
// Close PR
|
||||
await withRetry(
|
||||
() => github.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: issue_number,
|
||||
state: "closed",
|
||||
}),
|
||||
`Close PR #${issue_number}`
|
||||
)
|
||||
|
||||
closedCount++
|
||||
core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
|
||||
|
||||
// Delay before processing next PR
|
||||
await sleep(requestDelayMs)
|
||||
} catch (error) {
|
||||
skippedCount++
|
||||
core.error(`Failed to close PR #${issue_number}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed = Math.round((Date.now() - startTime) / 1000)
|
||||
core.info(`\n========== Summary ==========`)
|
||||
core.info(`Total open PRs found: ${allPrs.length}`)
|
||||
core.info(`Stale PRs identified: ${stalePrs.length}`)
|
||||
core.info(`PRs closed: ${closedCount}`)
|
||||
core.info(`PRs skipped (errors): ${skippedCount}`)
|
||||
core.info(`Elapsed time: ${elapsed}s`)
|
||||
core.info(`=============================`)
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.49",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -84,7 +84,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.49",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -119,7 +119,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.49",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -146,7 +146,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.49",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -168,7 +168,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.49",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -192,7 +192,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.49",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -253,7 +253,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.49",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -307,7 +307,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.49",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -337,7 +337,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.49",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -353,7 +353,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.49",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -366,7 +366,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.49",
|
||||
"dependencies": {
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
@@ -384,7 +384,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.49",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -520,7 +520,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.49",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -536,9 +536,9 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.2.10",
|
||||
"@opentui/keymap": ">=0.2.10",
|
||||
"@opentui/solid": ">=0.2.10",
|
||||
"@opentui/core": ">=0.2.9",
|
||||
"@opentui/keymap": ">=0.2.9",
|
||||
"@opentui/solid": ">=0.2.9",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
@@ -558,7 +558,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.49",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -573,7 +573,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.49",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -608,7 +608,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.49",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -657,7 +657,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.49",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -721,9 +721,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.2.10",
|
||||
"@opentui/keymap": "0.2.10",
|
||||
"@opentui/solid": "0.2.10",
|
||||
"@opentui/core": "0.2.9",
|
||||
"@opentui/keymap": "0.2.9",
|
||||
"@opentui/solid": "0.2.9",
|
||||
"@pierre/diffs": "1.1.0-beta.18",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -1590,23 +1590,23 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.2.10", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.10", "@opentui/core-darwin-x64": "0.2.10", "@opentui/core-linux-arm64": "0.2.10", "@opentui/core-linux-x64": "0.2.10", "@opentui/core-win32-arm64": "0.2.10", "@opentui/core-win32-x64": "0.2.10" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-oviCtx0jYjc7F8X2b8+0IkQLg6WH47Nwl6CFeZo5dU0k6OpSbTbi07ZleObaiECAp+S1YLhAtVdgzHU7hBZlaw=="],
|
||||
"@opentui/core": ["@opentui/core@0.2.9", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.9", "@opentui/core-darwin-x64": "0.2.9", "@opentui/core-linux-arm64": "0.2.9", "@opentui/core-linux-x64": "0.2.9", "@opentui/core-win32-arm64": "0.2.9", "@opentui/core-win32-x64": "0.2.9" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Kmeqi+yiDau+P45xDeX08GS50FK917qVwuPTN7HGxsQ9Byt7Iifq/6OMiSnFULBzoZtECdKLgQF1XwLsNm1wig=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+lbDDj42Og+UtTZEwlHhGXichmOlkxSqn0J+Jqjat5/Tt5oZykj1NZjFIQ7ZSz4Miz7EmZwgYKE2CyOmmm9MoQ=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-D2ne8Xgyrg71L/9lF7vPh30Sxz6+3yAqpT0m87WiI+040J7sQEyK3YM/7w5JKuVemQ4H54HSPjofrUHjfibjoQ=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-5iAoA0aqMWWAQ93nh8Bb0ipwt9h+tvEFc88+YO9St43uUJ+XrXcmMj3T8wtl6dSu/SN0UoDWNaUMHUmtykiPtg=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ymbbt/wN/vgB8g+kbHospJclVKHq6cdgfEYg9qgsSHp2vqMFBqlQQ692MS3BcZfX9jrKROK7NvC6Hj37X5K/7Q=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-EnrkxgH5K76Oi/Br1UHPZblXG5P60snmtySfnxuVaeECNZrbTkV6BV/A0WoBeWshJweGbx1D+eTF+sEEjQCi8w=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-0RIrVe4+42oELHtSJBaaYhngUeMKwSeqfdtKeSwEFwCzrqrNXxCpXQdOo8QvjOKGgng4Smn6O6KM8sgCj4SSPQ=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.10", "", { "os": "linux", "cpu": "x64" }, "sha512-fI+r3kCPqIxsWwPVGpKUQy4zHK8y+jkDRCwa3UbaUy48RQ44jMuf2RhVhmi4xmCvSc8UPJBbYsw1tLuh9kmXjg=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-fjCZP1IOLWm68FYl2PRzFg1vfu226FPfiJsdNtLbhaYF2uEZOB/v1BQph21OKnB7GC7X8GQatvhM5sS3DQ2MSQ=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-8F4z2hIRgkVWcr6CMVeJ9N4+1rmURPt2Pq2GBPko8ch6rxHR+a//KD1MfphyuLTHBS1tJ4vfZSWSoiaESImtrA=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-u8SP3u2QEJqcGIULYZ7Lkht9ss7wcN4/LnMuqt9rPOiCduFn/VW4r8lQCftZ6DRSqyoP9mJ1xLzOSFl98UYyEw=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.10", "", { "os": "win32", "cpu": "x64" }, "sha512-Ki+qNBlIFW5K2wcG/RHrlPp7yEQKXeiNX3mlje25iwX62Ac5w391HBpOmUjbPoq20McPyDRnhbLfbXQSPtickg=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-un7iSy9XHLwa6ouVpUj3eEGnXfPG50OMUJ2Dt30Jvn2vhNwIU2VO4RGx06l5OUD6GGVpHb0RqmG/384oo9i+HA=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.2.10", "", { "dependencies": { "@opentui/core": "0.2.10" }, "peerDependencies": { "@opentui/react": "0.2.10", "@opentui/solid": "0.2.10", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-80fU3Lr/98sNIpVYd8PApAeQw8A8D9BemyOGi6jGvTQCl0rxKgvaVBviDRGKxl1INTVjZy9By8UPncc2KJOuWQ=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.2.9", "", { "dependencies": { "@opentui/core": "0.2.9" }, "peerDependencies": { "@opentui/react": "0.2.9", "@opentui/solid": "0.2.9", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-yCc6L0Jqa8aVaNAVniTV5bNygJayUE6mxWfaBQY5VV5QwsZemXSeQQc4vP2eetH4Rrm1gGA59gLP+zh6+s5fvw=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.2.10", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.10", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-+4/MB90yIQiPwg8Y4wY092yva9BvRTsJeeeEO3e2H7P8k8zxYk4G9bzuhqYLxA9mTVQ+zVDlrmFoPQhT7vpIRw=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.2.9", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.9", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-qpNSCELxRvBAx8Zneqz46FYYTvJNFjDvhqzAAZRNoaHathfU6X6iPxWMUqP/9ls5VcHFW1TDJdgtpsq1N/nHMQ=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
|
||||
+26
-10
@@ -70,10 +70,11 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => {
|
||||
}).json
|
||||
}
|
||||
|
||||
const providerHttpErrorsQuery = () => {
|
||||
const providerHttpErrorsQuery = (product: "go" | "zen") => {
|
||||
const filters = [
|
||||
{ column: "provider", op: "exists" },
|
||||
{ column: "user_agent", op: "contains", value: "opencode" },
|
||||
{ column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" },
|
||||
]
|
||||
const successHttpStatus = calculatedField({
|
||||
name: "is_success_http_status",
|
||||
@@ -100,15 +101,11 @@ const providerHttpErrorsQuery = () => {
|
||||
name: "FAILED",
|
||||
column: failedProviderHttpStatus.name,
|
||||
filterCombination: "AND",
|
||||
filters: [
|
||||
...filters,
|
||||
{ column: "event_type", op: "=", value: "llm.error" },
|
||||
{ column: "llm.error.code", op: "!=", value: "404" },
|
||||
],
|
||||
filters: [...filters, { column: "event_type", op: "=", value: "llm.error" }],
|
||||
},
|
||||
],
|
||||
formulas: [
|
||||
{ name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 200), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" },
|
||||
{ name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 50), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" },
|
||||
],
|
||||
timeRange: 900,
|
||||
}).json
|
||||
@@ -218,10 +215,29 @@ new honeycomb.Trigger("LowModelTpsZen", {
|
||||
],
|
||||
})
|
||||
|
||||
new honeycomb.Trigger("IncreasedProviderHttpErrors", {
|
||||
name: "Increased Provider HTTP Errors",
|
||||
new honeycomb.Trigger("IncreasedProviderHttpErrorsGo", {
|
||||
name: "Increased Provider HTTP Errors [Go]",
|
||||
description,
|
||||
queryJson: providerHttpErrorsQuery(),
|
||||
queryJson: providerHttpErrorsQuery("go"),
|
||||
alertType: "on_change",
|
||||
frequency: 300,
|
||||
thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }],
|
||||
recipients: [
|
||||
{
|
||||
id: webhookRecipient.id,
|
||||
notificationDetails: [
|
||||
{
|
||||
variables: [{ name: "type", value: "provider_http_errors" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
new honeycomb.Trigger("IncreasedProviderHttpErrorsZen", {
|
||||
name: "Increased Provider HTTP Errors [Zen]",
|
||||
description,
|
||||
queryJson: providerHttpErrorsQuery("zen"),
|
||||
alertType: "on_change",
|
||||
frequency: 300,
|
||||
thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-Hw7sVV9rTm6qBMtdwfLIV2QvxvLQY5qrywXzuyYbhcs=",
|
||||
"aarch64-linux": "sha256-++oXnY7YqrYt0Qv7ZISmoHliARM9qEP8FacqLxGZH1c=",
|
||||
"aarch64-darwin": "sha256-kZVa0R1YbuvtTzpETqK6ddj4ISje5jBFHBdlynkhW7Q=",
|
||||
"x86_64-darwin": "sha256-94eagNDa8GGJxF8BsMX2BF5Pa+QTl48lXL1+6HgEn0I="
|
||||
"x86_64-linux": "sha256-QIj9PhOXR/GngV/dPjCF7n5rKro2fcTNGzJ47a41Z2Q=",
|
||||
"aarch64-linux": "sha256-fQl7BjjTYtRKT3HRVhubaIVww/puUFSTzVV5bTy8II8=",
|
||||
"aarch64-darwin": "sha256-81IAmdjiYZz8IgMJt0+VxzdOS80gTHc5SendwEW/vD4=",
|
||||
"x86_64-darwin": "sha256-5OMX4VVBMfEmkYvzd09oksAt5hKkxDs84miO804LBI8="
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -35,9 +35,9 @@
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.2.10",
|
||||
"@opentui/keymap": "0.2.10",
|
||||
"@opentui/solid": "0.2.10",
|
||||
"@opentui/core": "0.2.9",
|
||||
"@opentui/keymap": "0.2.9",
|
||||
"@opentui/solid": "0.2.9",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@types/luxon": "3.7.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.49",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -13,7 +13,6 @@ const statusLabels = {
|
||||
connected: "mcp.status.connected",
|
||||
failed: "mcp.status.failed",
|
||||
needs_auth: "mcp.status.needs_auth",
|
||||
needs_client_registration: "mcp.status.needs_client_registration",
|
||||
disabled: "mcp.status.disabled",
|
||||
} as const
|
||||
|
||||
@@ -32,16 +31,8 @@ export const DialogSelectMcp: Component = () => {
|
||||
|
||||
const toggle = useMutation(() => ({
|
||||
mutationFn: async (name: string) => {
|
||||
const status = sync.data.mcp[name]
|
||||
if (status?.status === "connected") {
|
||||
await sdk.client.mcp.disconnect({ name })
|
||||
return
|
||||
}
|
||||
if (status?.status === "needs_auth") {
|
||||
await sdk.client.mcp.auth.authenticate({ name })
|
||||
return
|
||||
}
|
||||
await sdk.client.mcp.connect({ name })
|
||||
if (sync.data.mcp[name]?.status === "connected") await sdk.client.mcp.disconnect({ name })
|
||||
else await sdk.client.mcp.connect({ name })
|
||||
},
|
||||
onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))),
|
||||
}))
|
||||
@@ -76,7 +67,7 @@ export const DialogSelectMcp: Component = () => {
|
||||
}
|
||||
const error = () => {
|
||||
const s = mcpStatus()
|
||||
if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error
|
||||
return s?.status === "failed" ? s.error : undefined
|
||||
}
|
||||
const enabled = () => status() === "connected"
|
||||
return (
|
||||
@@ -87,6 +78,9 @@ export const DialogSelectMcp: Component = () => {
|
||||
<Show when={statusLabel()}>
|
||||
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
|
||||
</Show>
|
||||
<Show when={toggle.isPending && toggle.variables === i.name}>
|
||||
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={error()}>
|
||||
<span class="text-11-regular text-text-weaker truncate">{error()}</span>
|
||||
|
||||
@@ -145,15 +145,7 @@ const useMcpToggleMutation = () => {
|
||||
return useMutation(() => ({
|
||||
mutationFn: async (name: string) => {
|
||||
const status = sync.data.mcp[name]
|
||||
if (status?.status === "connected") {
|
||||
await sdk.client.mcp.disconnect({ name })
|
||||
return
|
||||
}
|
||||
if (status?.status === "needs_auth") {
|
||||
await sdk.client.mcp.auth.authenticate({ name })
|
||||
return
|
||||
}
|
||||
await sdk.client.mcp.connect({ name })
|
||||
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
|
||||
},
|
||||
onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))),
|
||||
onError: (err) => {
|
||||
@@ -324,7 +316,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full min-h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||
onClick={() => {
|
||||
if (toggleMcp.isPending) return
|
||||
toggleMcp.mutate(name)
|
||||
@@ -341,16 +333,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
status() === "needs_auth" || status() === "needs_client_registration",
|
||||
}}
|
||||
/>
|
||||
<span class="flex flex-col min-w-0 flex-1">
|
||||
<span class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-14-regular text-text-base truncate">{name}</span>
|
||||
</span>
|
||||
<Show when={status() === "needs_auth"}>
|
||||
<span class="text-11-regular text-text-weaker truncate">
|
||||
{language.t("mcp.auth.clickToAuthenticate")}
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
<span class="text-14-regular text-text-base truncate flex-1">{name}</span>
|
||||
<div onClick={(event) => event.stopPropagation()}>
|
||||
<Switch
|
||||
checked={enabled()}
|
||||
|
||||
@@ -14,14 +14,12 @@ export function StatusPopover() {
|
||||
const sync = useSync()
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const ready = createMemo(() => server.healthy() === false || sync.data.mcp_ready)
|
||||
const mcpIssue = createMemo(() => {
|
||||
const healthy = createMemo(() => {
|
||||
const serverHealthy = server.healthy() === true
|
||||
const mcp = Object.values(sync.data.mcp ?? {})
|
||||
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
|
||||
const warn = mcp.some((item) => item.status === "needs_auth")
|
||||
if (failed) return "critical" as const
|
||||
if (warn) return "warning" as const
|
||||
const issue = mcp.some((item) => item.status !== "connected" && item.status !== "disabled")
|
||||
return serverHealthy && !issue
|
||||
})
|
||||
const healthy = createMemo(() => server.healthy() === true && !mcpIssue())
|
||||
|
||||
return (
|
||||
<Popover
|
||||
@@ -43,9 +41,7 @@ export function StatusPopover() {
|
||||
classList={{
|
||||
"absolute -top-px -right-px size-1.5 rounded-full": true,
|
||||
"bg-icon-success-base": ready() && healthy(),
|
||||
"bg-icon-warning-base": ready() && server.healthy() === true && mcpIssue() === "warning",
|
||||
"bg-icon-critical-base":
|
||||
server.healthy() === false || (ready() && server.healthy() === true && mcpIssue() === "critical"),
|
||||
"bg-icon-critical-base": server.healthy() === false || (ready() && !healthy()),
|
||||
"bg-border-weak-base": server.healthy() === undefined || !ready(),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -125,7 +125,6 @@ export function applyDirectoryEvent(input: {
|
||||
const info = (event.properties as { info: Session }).info
|
||||
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
||||
if (info.time.archived) {
|
||||
if (input.store.session[result.index]!.time.archived === info.time.archived) break
|
||||
if (result.found) {
|
||||
input.setStore(
|
||||
"session",
|
||||
|
||||
@@ -276,7 +276,6 @@ export const dict = {
|
||||
"mcp.status.connected": "متصل",
|
||||
"mcp.status.failed": "فشل",
|
||||
"mcp.status.needs_auth": "يحتاج إلى مصادقة",
|
||||
"mcp.auth.clickToAuthenticate": "انقر للمصادقة",
|
||||
"mcp.status.disabled": "معطل",
|
||||
"dialog.fork.empty": "لا توجد رسائل للتفرع منها",
|
||||
"dialog.directory.search.placeholder": "البحث في المجلدات",
|
||||
|
||||
@@ -276,7 +276,6 @@ export const dict = {
|
||||
"mcp.status.connected": "conectado",
|
||||
"mcp.status.failed": "falhou",
|
||||
"mcp.status.needs_auth": "precisa de autenticação",
|
||||
"mcp.auth.clickToAuthenticate": "Clique para autenticar",
|
||||
"mcp.status.disabled": "desabilitado",
|
||||
"dialog.fork.empty": "Nenhuma mensagem para bifurcar",
|
||||
"dialog.directory.search.placeholder": "Buscar pastas",
|
||||
|
||||
@@ -300,7 +300,6 @@ export const dict = {
|
||||
"mcp.status.connected": "povezano",
|
||||
"mcp.status.failed": "neuspjelo",
|
||||
"mcp.status.needs_auth": "potrebna autentifikacija",
|
||||
"mcp.auth.clickToAuthenticate": "Kliknite za autentifikaciju",
|
||||
"mcp.status.disabled": "onemogućeno",
|
||||
|
||||
"dialog.fork.empty": "Nema poruka za fork",
|
||||
|
||||
@@ -298,7 +298,6 @@ export const dict = {
|
||||
"mcp.status.connected": "forbundet",
|
||||
"mcp.status.failed": "mislykkedes",
|
||||
"mcp.status.needs_auth": "kræver godkendelse",
|
||||
"mcp.auth.clickToAuthenticate": "Klik for at godkende",
|
||||
"mcp.status.disabled": "deaktiveret",
|
||||
|
||||
"dialog.fork.empty": "Ingen beskeder at forgrene fra",
|
||||
|
||||
@@ -282,7 +282,6 @@ export const dict = {
|
||||
"mcp.status.connected": "verbunden",
|
||||
"mcp.status.failed": "fehlgeschlagen",
|
||||
"mcp.status.needs_auth": "benötigt Authentifizierung",
|
||||
"mcp.auth.clickToAuthenticate": "Zum Authentifizieren klicken",
|
||||
"mcp.status.disabled": "deaktiviert",
|
||||
"dialog.fork.empty": "Keine Nachrichten zum Abzweigen vorhanden",
|
||||
"dialog.directory.search.placeholder": "Ordner durchsuchen",
|
||||
|
||||
@@ -306,7 +306,6 @@ export const dict = {
|
||||
"mcp.status.failed": "failed",
|
||||
"mcp.status.needs_auth": "needs auth",
|
||||
"mcp.status.disabled": "disabled",
|
||||
"mcp.auth.clickToAuthenticate": "Click to authenticate",
|
||||
|
||||
"dialog.fork.empty": "No messages to fork from",
|
||||
|
||||
@@ -903,7 +902,7 @@ export const dict = {
|
||||
"settings.permissions.tool.read.title": "Read",
|
||||
"settings.permissions.tool.read.description": "Reading a file (matches the file path)",
|
||||
"settings.permissions.tool.edit.title": "Edit",
|
||||
"settings.permissions.tool.edit.description": "Modify files, including edits, writes, and patches",
|
||||
"settings.permissions.tool.edit.description": "Modify files, including edits, writes, patches, and multi-edits",
|
||||
"settings.permissions.tool.glob.title": "Glob",
|
||||
"settings.permissions.tool.glob.description": "Match files using glob patterns",
|
||||
"settings.permissions.tool.grep.title": "Grep",
|
||||
|
||||
@@ -299,7 +299,6 @@ export const dict = {
|
||||
"mcp.status.connected": "conectado",
|
||||
"mcp.status.failed": "fallido",
|
||||
"mcp.status.needs_auth": "necesita auth",
|
||||
"mcp.auth.clickToAuthenticate": "Haz clic para autenticar",
|
||||
"mcp.status.disabled": "deshabilitado",
|
||||
|
||||
"dialog.fork.empty": "No hay mensajes desde donde bifurcar",
|
||||
|
||||
@@ -277,7 +277,6 @@ export const dict = {
|
||||
"mcp.status.connected": "connecté",
|
||||
"mcp.status.failed": "échoué",
|
||||
"mcp.status.needs_auth": "nécessite auth",
|
||||
"mcp.auth.clickToAuthenticate": "Cliquez pour vous authentifier",
|
||||
"mcp.status.disabled": "désactivé",
|
||||
"dialog.fork.empty": "Aucun message à partir duquel bifurquer",
|
||||
"dialog.directory.search.placeholder": "Rechercher des dossiers",
|
||||
|
||||
@@ -275,7 +275,6 @@ export const dict = {
|
||||
"mcp.status.connected": "接続済み",
|
||||
"mcp.status.failed": "失敗",
|
||||
"mcp.status.needs_auth": "認証が必要",
|
||||
"mcp.auth.clickToAuthenticate": "クリックして認証",
|
||||
"mcp.status.disabled": "無効",
|
||||
"dialog.fork.empty": "フォーク元のメッセージがありません",
|
||||
"dialog.directory.search.placeholder": "フォルダを検索",
|
||||
|
||||
@@ -275,7 +275,6 @@ export const dict = {
|
||||
"mcp.status.connected": "연결됨",
|
||||
"mcp.status.failed": "실패",
|
||||
"mcp.status.needs_auth": "인증 필요",
|
||||
"mcp.auth.clickToAuthenticate": "클릭하여 인증",
|
||||
"mcp.status.disabled": "비활성화됨",
|
||||
"dialog.fork.empty": "분기할 메시지 없음",
|
||||
"dialog.directory.search.placeholder": "폴더 검색",
|
||||
|
||||
@@ -302,7 +302,6 @@ export const dict = {
|
||||
"mcp.status.connected": "tilkoblet",
|
||||
"mcp.status.failed": "mislyktes",
|
||||
"mcp.status.needs_auth": "trenger autentisering",
|
||||
"mcp.auth.clickToAuthenticate": "Klikk for å autentisere",
|
||||
"mcp.status.disabled": "deaktivert",
|
||||
|
||||
"dialog.fork.empty": "Ingen meldinger å forgrene fra",
|
||||
|
||||
@@ -277,7 +277,6 @@ export const dict = {
|
||||
"mcp.status.connected": "połączono",
|
||||
"mcp.status.failed": "niepowodzenie",
|
||||
"mcp.status.needs_auth": "wymaga autoryzacji",
|
||||
"mcp.auth.clickToAuthenticate": "Kliknij, aby się uwierzytelnić",
|
||||
"mcp.status.disabled": "wyłączone",
|
||||
"dialog.fork.empty": "Brak wiadomości do rozwidlenia",
|
||||
"dialog.directory.search.placeholder": "Szukaj folderów",
|
||||
|
||||
@@ -299,7 +299,6 @@ export const dict = {
|
||||
"mcp.status.connected": "подключено",
|
||||
"mcp.status.failed": "ошибка",
|
||||
"mcp.status.needs_auth": "требуется авторизация",
|
||||
"mcp.auth.clickToAuthenticate": "Нажмите, чтобы авторизоваться",
|
||||
"mcp.status.disabled": "отключено",
|
||||
|
||||
"dialog.fork.empty": "Нет сообщений для ответвления",
|
||||
|
||||
@@ -299,7 +299,6 @@ export const dict = {
|
||||
"mcp.status.connected": "เชื่อมต่อแล้ว",
|
||||
"mcp.status.failed": "ล้มเหลว",
|
||||
"mcp.status.needs_auth": "ต้องการการตรวจสอบสิทธิ์",
|
||||
"mcp.auth.clickToAuthenticate": "คลิกเพื่อยืนยันตัวตน",
|
||||
"mcp.status.disabled": "ปิดใช้งาน",
|
||||
|
||||
"dialog.fork.empty": "ไม่มีข้อความให้แตกแขนง",
|
||||
|
||||
@@ -304,7 +304,6 @@ export const dict = {
|
||||
"mcp.status.connected": "bağlı",
|
||||
"mcp.status.failed": "başarısız",
|
||||
"mcp.status.needs_auth": "kimlik doğrulama gerekli",
|
||||
"mcp.auth.clickToAuthenticate": "Kimlik doğrulamak için tıklayın",
|
||||
"mcp.status.disabled": "devre dışı",
|
||||
|
||||
"dialog.fork.empty": "Dallandırılacak mesaj yok",
|
||||
|
||||
@@ -319,7 +319,6 @@ export const dict = {
|
||||
"mcp.status.connected": "已连接",
|
||||
"mcp.status.failed": "失败",
|
||||
"mcp.status.needs_auth": "需要授权",
|
||||
"mcp.auth.clickToAuthenticate": "点击进行授权",
|
||||
"mcp.status.disabled": "已禁用",
|
||||
|
||||
"dialog.fork.empty": "没有可用于分叉的消息",
|
||||
|
||||
@@ -299,7 +299,6 @@ export const dict = {
|
||||
"mcp.status.connected": "已連線",
|
||||
"mcp.status.failed": "失敗",
|
||||
"mcp.status.needs_auth": "需要授權",
|
||||
"mcp.auth.clickToAuthenticate": "點擊以進行授權",
|
||||
"mcp.status.disabled": "已停用",
|
||||
|
||||
"dialog.fork.empty": "沒有可用於分支的訊息",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.49",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -9,8 +9,8 @@ export const config = {
|
||||
github: {
|
||||
repoUrl: "https://github.com/anomalyco/opencode",
|
||||
starsFormatted: {
|
||||
compact: "160K",
|
||||
full: "160,000",
|
||||
compact: "150K",
|
||||
full: "150,000",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -22,8 +22,8 @@ export const config = {
|
||||
|
||||
// Static stats (used on landing page)
|
||||
stats: {
|
||||
contributors: "900",
|
||||
commits: "13,000",
|
||||
monthlyUsers: "7.5M",
|
||||
contributors: "850",
|
||||
commits: "11,000",
|
||||
monthlyUsers: "6.5M",
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.49",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.49",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.49",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.49",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -5,8 +5,7 @@ import { produce, type Draft } from "immer"
|
||||
import { ModelV2 } from "./model"
|
||||
import { PluginV2 } from "./plugin"
|
||||
import { ProviderV2 } from "./provider"
|
||||
import { Location } from "./location"
|
||||
import { EventV2 } from "./event"
|
||||
import { Instance } from "./instance"
|
||||
|
||||
type ProviderRecord = {
|
||||
provider: ProviderV2.Info
|
||||
@@ -25,15 +24,6 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundErr
|
||||
modelID: ModelV2.ID,
|
||||
}) {}
|
||||
|
||||
export const Event = {
|
||||
ModelUpdated: EventV2.define({
|
||||
type: "catalog.model.updated",
|
||||
schema: {
|
||||
model: ModelV2.Info,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly provider: {
|
||||
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info, ProviderNotFoundError>
|
||||
@@ -67,11 +57,10 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
yield* Location.Service
|
||||
yield* Instance.Service
|
||||
let records = HashMap.empty<ProviderV2.ID, ProviderRecord>()
|
||||
let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined
|
||||
const plugin = yield* PluginV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
|
||||
const resolve = (model: ModelV2.Info) => {
|
||||
const provider = Option.getOrThrow(HashMap.get(records, model.providerID)).provider
|
||||
@@ -168,12 +157,14 @@ export const layer = Layer.effect(
|
||||
)
|
||||
const updated = yield* plugin.trigger("model.update", {}, { model, cancel: false })
|
||||
if (updated.cancel) return
|
||||
const next = new ModelV2.Info({ ...updated.model, id: modelID, providerID })
|
||||
records = HashMap.set(records, providerID, {
|
||||
provider: record.provider,
|
||||
models: HashMap.set(record.models, modelID, next),
|
||||
models: HashMap.set(
|
||||
record.models,
|
||||
modelID,
|
||||
new ModelV2.Info({ ...updated.model, id: modelID, providerID }),
|
||||
),
|
||||
})
|
||||
yield* events.publish(Event.ModelUpdated, { model: resolve(next) })
|
||||
return
|
||||
}),
|
||||
|
||||
@@ -266,4 +257,4 @@ export const layer = Layer.effect(
|
||||
|
||||
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide(PluginV2.defaultLayer))
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import { Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { Location } from "./location"
|
||||
import { withStatics } from "./schema"
|
||||
import { Identifier } from "./util/identifier"
|
||||
|
||||
export const ID = Schema.String.pipe(
|
||||
Schema.brand("Event.ID"),
|
||||
withStatics((schema) => ({ create: () => schema.make("evt_" + Identifier.ascending()) })),
|
||||
)
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export type Definition<Type extends string = string, DataSchema extends Schema.Top = Schema.Top> = {
|
||||
readonly type: Type
|
||||
readonly version?: number
|
||||
readonly aggregate?: string
|
||||
readonly data: DataSchema
|
||||
}
|
||||
|
||||
export type Data<D extends Definition> = Schema.Schema.Type<D["data"]>
|
||||
|
||||
export type Payload<D extends Definition = Definition> = {
|
||||
readonly id: ID
|
||||
readonly type: D["type"]
|
||||
readonly data: Data<D>
|
||||
readonly version?: number
|
||||
readonly location?: Location.Ref
|
||||
readonly metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type Sync = (event: Payload) => Effect.Effect<void>
|
||||
|
||||
export const registry = new Map<string, Definition>()
|
||||
|
||||
export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
|
||||
readonly type: Type
|
||||
readonly version?: number
|
||||
readonly aggregate?: string
|
||||
readonly schema: Fields
|
||||
}): Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> & Definition<Type, Schema.Struct<Fields>> {
|
||||
const Data = Schema.Struct(input.schema)
|
||||
const Payload = Schema.Struct({
|
||||
id: ID,
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
type: Schema.Literal(input.type),
|
||||
version: Schema.optional(Schema.Number),
|
||||
location: Schema.optional(Location.Ref),
|
||||
data: Data,
|
||||
}).annotate({ identifier: input.type })
|
||||
|
||||
const definition = Object.assign(Payload, {
|
||||
type: input.type,
|
||||
...(input.version === undefined ? {} : { version: input.version }),
|
||||
...(input.aggregate === undefined ? {} : { aggregate: input.aggregate }),
|
||||
data: Data,
|
||||
})
|
||||
registry.set(input.type, definition)
|
||||
return definition as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> &
|
||||
Definition<Type, Schema.Struct<Fields>>
|
||||
}
|
||||
|
||||
export function definitions() {
|
||||
return registry.values().toArray()
|
||||
}
|
||||
|
||||
export interface PublishOptions {
|
||||
readonly id?: ID
|
||||
readonly metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type Unsubscribe = Effect.Effect<void>
|
||||
|
||||
export interface Interface {
|
||||
readonly publish: <D extends Definition>(
|
||||
definition: D,
|
||||
data: Data<D>,
|
||||
options?: PublishOptions,
|
||||
) => Effect.Effect<Payload<D>>
|
||||
readonly publishEvent: <D extends Definition>(event: Payload<D>) => Effect.Effect<Payload<D>>
|
||||
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
|
||||
readonly all: () => Stream.Stream<Payload>
|
||||
readonly sync: (handler: Sync) => Effect.Effect<Unsubscribe>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const all = yield* PubSub.unbounded<Payload>()
|
||||
const typed = new Map<string, PubSub.PubSub<Payload>>()
|
||||
const syncHandlers = new Array<Sync>()
|
||||
|
||||
const getOrCreate = (definition: Definition) =>
|
||||
Effect.gen(function* () {
|
||||
const existing = typed.get(definition.type)
|
||||
if (existing) return existing
|
||||
const pubsub = yield* PubSub.unbounded<Payload>()
|
||||
typed.set(definition.type, pubsub)
|
||||
return pubsub
|
||||
})
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
yield* PubSub.shutdown(all)
|
||||
yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true })
|
||||
}),
|
||||
)
|
||||
|
||||
function publishEvent<D extends Definition>(event: Payload<D>) {
|
||||
return Effect.gen(function* () {
|
||||
for (const sync of syncHandlers) {
|
||||
yield* sync(event as Payload)
|
||||
}
|
||||
const pubsub = typed.get(event.type)
|
||||
if (pubsub) yield* PubSub.publish(pubsub, event as Payload)
|
||||
yield* PubSub.publish(all, event as Payload)
|
||||
return event
|
||||
})
|
||||
}
|
||||
|
||||
function publish<D extends Definition>(definition: D, data: Data<D>, options?: PublishOptions) {
|
||||
return Effect.gen(function* () {
|
||||
const location = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
|
||||
const event = {
|
||||
id: options?.id ?? ID.create(),
|
||||
...(options?.metadata ? { metadata: options.metadata } : {}),
|
||||
type: definition.type,
|
||||
...(definition.version === undefined ? {} : { version: definition.version }),
|
||||
...(location ? { location } : {}),
|
||||
data,
|
||||
} as Payload<D>
|
||||
return yield* publishEvent(event)
|
||||
})
|
||||
}
|
||||
|
||||
const subscribe = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> =>
|
||||
Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe(
|
||||
Stream.map((event) => event as Payload<D>),
|
||||
)
|
||||
|
||||
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(all)
|
||||
const sync = (handler: Sync): Effect.Effect<Unsubscribe> =>
|
||||
Effect.sync(() => {
|
||||
syncHandlers.push(handler)
|
||||
return Effect.sync(() => {
|
||||
const index = syncHandlers.indexOf(handler)
|
||||
if (index >= 0) syncHandlers.splice(index, 1)
|
||||
})
|
||||
})
|
||||
|
||||
return Service.of({ publish, publishEvent, subscribe, all: streamAll, sync })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
export * as EventV2 from "./event"
|
||||
@@ -5,13 +5,29 @@ function truthy(key: string) {
|
||||
return value === "true" || value === "1"
|
||||
}
|
||||
|
||||
function falsy(key: string) {
|
||||
const value = process.env[key]?.toLowerCase()
|
||||
return value === "false" || value === "0"
|
||||
}
|
||||
|
||||
function number(key: string) {
|
||||
const value = process.env[key]
|
||||
if (!value) return undefined
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
|
||||
}
|
||||
|
||||
const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
|
||||
const OPENCODE_DISABLE_CLAUDE_CODE = truthy("OPENCODE_DISABLE_CLAUDE_CODE")
|
||||
const OPENCODE_DISABLE_CLAUDE_CODE_SKILLS =
|
||||
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")
|
||||
const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
|
||||
|
||||
export const Flag = {
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: process.env["OTEL_EXPORTER_OTLP_ENDPOINT"],
|
||||
OTEL_EXPORTER_OTLP_HEADERS: process.env["OTEL_EXPORTER_OTLP_HEADERS"],
|
||||
|
||||
OPENCODE_AUTO_SHARE: truthy("OPENCODE_AUTO_SHARE"),
|
||||
OPENCODE_AUTO_HEAP_SNAPSHOT: truthy("OPENCODE_AUTO_HEAP_SNAPSHOT"),
|
||||
OPENCODE_GIT_BASH_PATH: process.env["OPENCODE_GIT_BASH_PATH"],
|
||||
OPENCODE_CONFIG: process.env["OPENCODE_CONFIG"],
|
||||
@@ -22,29 +38,54 @@ export const Flag = {
|
||||
OPENCODE_DISABLE_TERMINAL_TITLE: truthy("OPENCODE_DISABLE_TERMINAL_TITLE"),
|
||||
OPENCODE_SHOW_TTFD: truthy("OPENCODE_SHOW_TTFD"),
|
||||
OPENCODE_PERMISSION: process.env["OPENCODE_PERMISSION"],
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS"),
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: truthy("OPENCODE_DISABLE_LSP_DOWNLOAD"),
|
||||
OPENCODE_ENABLE_EXPERIMENTAL_MODELS: truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS"),
|
||||
OPENCODE_DISABLE_AUTOCOMPACT: truthy("OPENCODE_DISABLE_AUTOCOMPACT"),
|
||||
OPENCODE_DISABLE_MODELS_FETCH: truthy("OPENCODE_DISABLE_MODELS_FETCH"),
|
||||
OPENCODE_DISABLE_MOUSE: truthy("OPENCODE_DISABLE_MOUSE"),
|
||||
OPENCODE_DISABLE_CLAUDE_CODE,
|
||||
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
|
||||
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS,
|
||||
OPENCODE_DISABLE_EXTERNAL_SKILLS: truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
|
||||
OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"],
|
||||
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
|
||||
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
|
||||
OPENCODE_ENABLE_QUESTION_TOOL: truthy("OPENCODE_ENABLE_QUESTION_TOOL"),
|
||||
|
||||
// Experimental
|
||||
OPENCODE_EXPERIMENTAL,
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_FILEWATCHER").pipe(
|
||||
Config.withDefault(false),
|
||||
),
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER").pipe(
|
||||
Config.withDefault(false),
|
||||
),
|
||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT:
|
||||
copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"),
|
||||
OPENCODE_EXPERIMENTAL_MINIMAL_THINKING: truthy("OPENCODE_EXPERIMENTAL_MINIMAL_THINKING"),
|
||||
OPENCODE_ENABLE_EXA: truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA"),
|
||||
OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: number("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
|
||||
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: number("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
|
||||
OPENCODE_EXPERIMENTAL_OXFMT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT"),
|
||||
OPENCODE_EXPERIMENTAL_LSP_TY: truthy("OPENCODE_EXPERIMENTAL_LSP_TY"),
|
||||
OPENCODE_EXPERIMENTAL_LSP_TOOL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
|
||||
OPENCODE_EXPERIMENTAL_PLAN_MODE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
|
||||
OPENCODE_EXPERIMENTAL_SCOUT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SCOUT"),
|
||||
OPENCODE_EXPERIMENTAL_MARKDOWN: !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN"),
|
||||
OPENCODE_ENABLE_PARALLEL: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"),
|
||||
OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"],
|
||||
OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"],
|
||||
OPENCODE_DISABLE_EMBEDDED_WEB_UI: truthy("OPENCODE_DISABLE_EMBEDDED_WEB_UI"),
|
||||
OPENCODE_DB: process.env["OPENCODE_DB"],
|
||||
OPENCODE_DISABLE_CHANNEL_DB: truthy("OPENCODE_DISABLE_CHANNEL_DB"),
|
||||
OPENCODE_SKIP_MIGRATIONS: truthy("OPENCODE_SKIP_MIGRATIONS"),
|
||||
OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"),
|
||||
|
||||
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
|
||||
OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"),
|
||||
OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
|
||||
OPENCODE_EXPERIMENTAL_SESSION_SWITCHING: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SESSION_SWITCHING"),
|
||||
|
||||
// Evaluated at access time (not module load) because tests, the CLI, and
|
||||
// external tooling set these env vars at runtime.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Layer, LayerMap } from "effect"
|
||||
import { Instance } from "./instance"
|
||||
import { Catalog } from "./catalog"
|
||||
import { PluginBoot } from "./plugin/boot"
|
||||
|
||||
export class InstanceServiceMap extends LayerMap.Service<InstanceServiceMap>()("@opencode/example/InstanceServiceMap", {
|
||||
lookup: (ref: Instance.Ref) => {
|
||||
const instance = Layer.succeed(Instance.Service, Instance.Service.of(ref))
|
||||
return Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(Layer.provide(instance))
|
||||
},
|
||||
idleTimeToLive: "5 minutes",
|
||||
}) {}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Context } from "effect"
|
||||
|
||||
export * as Instance from "./instance"
|
||||
|
||||
export type Ref = {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Ref>()("@opencode/Instance") {}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Layer, LayerMap } from "effect"
|
||||
import { Location } from "./location"
|
||||
import { Catalog } from "./catalog"
|
||||
import { PluginBoot } from "./plugin/boot"
|
||||
|
||||
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
|
||||
lookup: (ref: Location.Ref) => {
|
||||
const location = Layer.succeed(Location.Service, Location.Service.of(ref))
|
||||
return Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(Layer.provide(location))
|
||||
},
|
||||
idleTimeToLive: "5 minutes",
|
||||
}) {}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Context, Schema } from "effect"
|
||||
|
||||
export * as Location from "./location"
|
||||
|
||||
export const Ref = Schema.Struct({
|
||||
directory: Schema.String,
|
||||
workspaceID: Schema.optional(Schema.String),
|
||||
}).annotate({ identifier: "Location.Ref" })
|
||||
export type Ref = typeof Ref.Type
|
||||
|
||||
export class Service extends Context.Service<Service, Ref>()("@opencode/Location") {}
|
||||
@@ -10,7 +10,6 @@ export const NvidiaPlugin = PluginV2.define({
|
||||
if (evt.provider.id !== ProviderV2.ID.make("nvidia")) return
|
||||
evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
evt.provider.options.headers["X-Title"] = "opencode"
|
||||
evt.provider.options.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode"
|
||||
}),
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
export * as Session from "./session"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { withStatics } from "./schema"
|
||||
import { Identifier } from "./util/identifier"
|
||||
|
||||
export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
|
||||
Schema.brand("SessionID"),
|
||||
withStatics((schema) => ({
|
||||
descending: (id?: string) => schema.make(id ?? "ses_" + Identifier.descending()),
|
||||
})),
|
||||
)
|
||||
export type ID = typeof ID.Type
|
||||
@@ -1,21 +1,14 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Fiber, Layer, Option, Stream } from "effect"
|
||||
import { DateTime, Effect, Layer, Option } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Instance } from "@opencode-ai/core/instance"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
|
||||
const it = testEffect(
|
||||
Catalog.layer.pipe(
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(PluginV2.defaultLayer),
|
||||
Layer.provideMerge(locationLayer),
|
||||
),
|
||||
)
|
||||
const instanceLayer = Layer.succeed(Instance.Service, Instance.Service.of({ directory: "test" }))
|
||||
const it = testEffect(Catalog.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer), Layer.provide(instanceLayer)))
|
||||
|
||||
describe("CatalogV2", () => {
|
||||
it.effect("normalizes provider baseURL into endpoint url", () =>
|
||||
@@ -76,31 +69,6 @@ describe("CatalogV2", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("publishes model updated events", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const events = yield* EventV2.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const modelID = ModelV2.ID.make("model")
|
||||
const fiber = yield* events
|
||||
.subscribe(Catalog.Event.ModelUpdated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
|
||||
yield* Effect.yieldNow
|
||||
yield* catalog.provider.update(providerID, () => {})
|
||||
yield* catalog.model.update(providerID, modelID, (model) => {
|
||||
model.name = "Updated Model"
|
||||
})
|
||||
const event = Array.from(yield* Fiber.join(fiber))[0]
|
||||
|
||||
expect(event?.type).toBe("catalog.model.updated")
|
||||
expect(event?.data.model.providerID).toBe(providerID)
|
||||
expect(event?.data.model.id).toBe(modelID)
|
||||
expect(event?.data.model.name).toBe("Updated Model")
|
||||
expect(event?.location).toEqual({ directory: "test" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves unknown model endpoint from provider endpoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of({ directory: "project", workspaceID: "workspace" }),
|
||||
)
|
||||
const it = testEffect(EventV2.layer.pipe(Layer.provideMerge(locationLayer)))
|
||||
const itWithoutLocation = testEffect(EventV2.layer)
|
||||
|
||||
const Message = EventV2.define({
|
||||
type: "test.message",
|
||||
schema: {
|
||||
text: Schema.String,
|
||||
},
|
||||
})
|
||||
|
||||
const GlobalMessage = EventV2.define({
|
||||
type: "test.global",
|
||||
schema: {
|
||||
text: Schema.String,
|
||||
},
|
||||
})
|
||||
|
||||
const VersionedMessage = EventV2.define({
|
||||
type: "test.versioned",
|
||||
version: 2,
|
||||
schema: {
|
||||
text: Schema.String,
|
||||
},
|
||||
})
|
||||
|
||||
describe("EventV2", () => {
|
||||
it.effect("publishes events with the current location", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const fiber = yield* events.subscribe(Message).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
const event = yield* events.publish(Message, { text: "hello" })
|
||||
const received = Array.from(yield* Fiber.join(fiber))
|
||||
|
||||
expect(received).toEqual([event])
|
||||
expect(event.type).toBe("test.message")
|
||||
expect(event).not.toHaveProperty("version")
|
||||
expect(event.data).toEqual({ text: "hello" })
|
||||
expect(event.location).toEqual({ directory: "project", workspaceID: "workspace" })
|
||||
}),
|
||||
)
|
||||
|
||||
itWithoutLocation.effect("omits location when no location is available", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const event = yield* events.publish(GlobalMessage, { text: "hello" })
|
||||
|
||||
expect(event).not.toHaveProperty("location")
|
||||
expect(event.type).toBe("test.global")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("publishes definition version", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const event = yield* events.publish(VersionedMessage, { text: "hello" })
|
||||
|
||||
expect(event.type).toBe("test.versioned")
|
||||
expect(event.version).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stores definitions in the exported registry", () =>
|
||||
Effect.sync(() => {
|
||||
expect(EventV2.registry.get(Message.type)).toBe(Message)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("publishes to typed and wildcard subscriptions", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const typed = yield* events.subscribe(Message).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
const wildcard = yield* events.all().pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
const event = yield* events.publish(Message, { text: "hello" })
|
||||
|
||||
expect(Array.from(yield* Fiber.join(typed))).toEqual([event])
|
||||
expect(Array.from(yield* Fiber.join(wildcard))).toEqual([event])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("runs sync handlers inline", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const received = new Array<EventV2.Payload>()
|
||||
const unsubscribe = yield* events.sync((event) =>
|
||||
Effect.sync(() => {
|
||||
received.push(event)
|
||||
}),
|
||||
)
|
||||
|
||||
const event = yield* events.publish(Message, { text: "hello" })
|
||||
yield* unsubscribe
|
||||
yield* events.publish(Message, { text: "after unsubscribe" })
|
||||
|
||||
expect(received).toEqual([event])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("runs sync handlers before publishing to streams", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const received = new Array<string>()
|
||||
const fiber = yield* events.all().pipe(
|
||||
Stream.take(1),
|
||||
Stream.runForEach(() => Effect.sync(() => received.push("stream"))),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* events.sync((event) =>
|
||||
Effect.sync(() => {
|
||||
received.push(event.type)
|
||||
}),
|
||||
)
|
||||
|
||||
yield* Effect.yieldNow
|
||||
yield* events.publish(Message, { text: "hello" })
|
||||
yield* Fiber.join(fiber)
|
||||
|
||||
expect(received).toEqual([Message.type, "stream"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -15,7 +15,7 @@ describe("NvidiaPlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("applies NVIDIA tracking headers only to nvidia", () =>
|
||||
it.effect("applies legacy referer headers only to nvidia", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* plugin.add(NvidiaPlugin)
|
||||
@@ -34,60 +34,8 @@ describe("NvidiaPlugin", () => {
|
||||
Existing: "value",
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
|
||||
})
|
||||
expect(ignored.provider.options.headers).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adds billing origin for custom NVIDIA endpoints", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* plugin.add(NvidiaPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("nvidia", {
|
||||
endpoint: { type: "aisdk", package: "test-provider", url: "http://localhost:8000/v1" },
|
||||
options: { headers: {}, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
)
|
||||
|
||||
expect(result.provider.options.headers).toEqual({
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves an explicit NVIDIA billing origin header", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* plugin.add(NvidiaPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("nvidia", {
|
||||
options: {
|
||||
headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" },
|
||||
body: {},
|
||||
aisdk: { provider: { baseURL: "https://integrate.api.nvidia.com/v1" }, request: {} },
|
||||
},
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
)
|
||||
|
||||
expect(result.provider.options.headers).toEqual({
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
"X-BILLING-INVOKE-ORIGIN": "CustomOrigin",
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Layer, Option } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Instance } from "@opencode-ai/core/instance"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
|
||||
@@ -9,7 +9,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { it, model, provider, withEnv } from "./provider-helper"
|
||||
|
||||
const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
|
||||
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
|
||||
const instanceLayer = Layer.succeed(Instance.Service, Instance.Service.of({ directory: "test" }))
|
||||
|
||||
describe("OpencodePlugin", () => {
|
||||
it.effect("uses a public key and cancels paid models without credentials", () =>
|
||||
@@ -192,6 +192,6 @@ describe("OpencodePlugin", () => {
|
||||
const selected = yield* catalog.model.small(providerID)
|
||||
|
||||
expect(Option.getOrUndefined(selected)?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
|
||||
}).pipe(Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(locationLayer)))),
|
||||
}).pipe(Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(instanceLayer)))),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.49",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -58,7 +58,7 @@ async function checkMacosApp(appName: string) {
|
||||
async function resolveWindowsAppPath(appName: string): Promise<string | null> {
|
||||
let output: string
|
||||
try {
|
||||
output = await execFilePromise("where", [appName]).then((r) => r.stdout.toString())
|
||||
output = execFilePromise("where", [appName]).toString()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -19,7 +19,7 @@ declare module "virtual:opencode-server" {
|
||||
export const init: typeof import("../../../opencode/dist/types/src/node").Log.init
|
||||
}
|
||||
export namespace Database {
|
||||
export const getPath: typeof import("../../../opencode/dist/types/src/node").Database.getPath
|
||||
export const Path: typeof import("../../../opencode/dist/types/src/node").Database.Path
|
||||
export const Client: typeof import("../../../opencode/dist/types/src/node").Database.Client
|
||||
}
|
||||
export namespace JsonMigration {
|
||||
|
||||
@@ -84,7 +84,6 @@ export function createMainWindow() {
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
title: "OpenCode",
|
||||
icon: iconPath(),
|
||||
backgroundColor,
|
||||
@@ -143,7 +142,6 @@ export function createLoadingWindow() {
|
||||
resizable: false,
|
||||
center: true,
|
||||
show: true,
|
||||
autoHideMenuBar: true,
|
||||
icon: iconPath(),
|
||||
backgroundColor,
|
||||
...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.49",
|
||||
"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.14.49"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.49/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.49/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.49/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.49/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.49/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.49",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -166,11 +166,11 @@ import { Effect } from "effect"
|
||||
|
||||
const audit = Effect.gen(function* () {
|
||||
const cassettes = yield* HttpRecorder.Cassette.Service
|
||||
const names = yield* cassettes.list()
|
||||
const issues = yield* Effect.forEach(names, (name) =>
|
||||
const entries = yield* cassettes.list()
|
||||
const issues = yield* Effect.forEach(entries, (entry) =>
|
||||
cassettes
|
||||
.read(name)
|
||||
.pipe(Effect.map((interactions) => ({ name, findings: HttpRecorder.secretFindings(interactions) }))),
|
||||
.read(entry.name)
|
||||
.pipe(Effect.map((interactions) => ({ name: entry.name, findings: HttpRecorder.secretFindings(interactions) }))),
|
||||
)
|
||||
return issues.filter((i) => i.findings.length > 0)
|
||||
})
|
||||
@@ -196,13 +196,14 @@ type RecordReplayOptions = {
|
||||
|
||||
## Layout
|
||||
|
||||
| File | Purpose |
|
||||
| -------------- | --------------------------------------------------------------------------- |
|
||||
| `effect.ts` | `cassetteLayer` / `recordingLayer` — the `HttpClient` adapter. |
|
||||
| `websocket.ts` | `makeWebSocketExecutor` — WebSocket record/replay. |
|
||||
| `cassette.ts` | `Cassette.Service` — `fileSystem` / `memory` adapters, error types. |
|
||||
| `recorder.ts` | Shared transport plumbing: `resolveAutoMode`, `ReplayState`. |
|
||||
| `redactor.ts` | Composable `Redactor` — headers, url, body redaction. |
|
||||
| `redaction.ts` | Lower-level header/URL primitives + secret pattern detection. |
|
||||
| `schema.ts` | Effect Schema definitions for the cassette JSON format. |
|
||||
| `matching.ts` | Request matcher, canonicalization, sequential cursor, mismatch diagnostics. |
|
||||
| File | Purpose |
|
||||
| -------------- | -------------------------------------------------------------------------------- |
|
||||
| `effect.ts` | `cassetteLayer` / `recordingLayer` — the `HttpClient` adapter. |
|
||||
| `websocket.ts` | `makeWebSocketExecutor` — WebSocket record/replay. |
|
||||
| `cassette.ts` | `Cassette.Service` — reads/writes cassette files, accumulates state. |
|
||||
| `recorder.ts` | Shared transport plumbing: `UnsafeCassetteError`, `appendOrFail`, `ReplayState`. |
|
||||
| `redactor.ts` | Composable `Redactor` — headers, url, body redaction. |
|
||||
| `redaction.ts` | Lower-level header/URL primitives + secret pattern detection. |
|
||||
| `schema.ts` | Effect Schema definitions for the cassette JSON format. |
|
||||
| `storage.ts` | Path resolution, JSON encode/decode, sync existence check. |
|
||||
| `matching.ts` | Request matcher, canonicalization, sequential cursor, mismatch diagnostics. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.49",
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { secretFindings, SecretFindingSchema, type SecretFinding } from "./redaction"
|
||||
import { secretFindings, type SecretFinding } from "./redaction"
|
||||
import { decodeCassette, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema"
|
||||
|
||||
const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
|
||||
@@ -14,24 +14,13 @@ export class CassetteNotFoundError extends Schema.TaggedErrorClass<CassetteNotFo
|
||||
}
|
||||
}
|
||||
|
||||
export class UnsafeCassetteError extends Schema.TaggedErrorClass<UnsafeCassetteError>()("UnsafeCassetteError", {
|
||||
cassetteName: Schema.String,
|
||||
findings: Schema.Array(SecretFindingSchema),
|
||||
}) {
|
||||
override get message() {
|
||||
return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings
|
||||
.map((finding) => `${finding.path} (${finding.reason})`)
|
||||
.join(", ")}`
|
||||
}
|
||||
export interface AppendResult {
|
||||
readonly findings: ReadonlyArray<SecretFinding>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly read: (name: string) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError>
|
||||
readonly append: (
|
||||
name: string,
|
||||
interaction: Interaction,
|
||||
metadata?: CassetteMetadata,
|
||||
) => Effect.Effect<void, UnsafeCassetteError>
|
||||
readonly append: (name: string, interaction: Interaction, metadata?: CassetteMetadata) => Effect.Effect<AppendResult>
|
||||
readonly exists: (name: string) => Effect.Effect<boolean>
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<string>>
|
||||
}
|
||||
@@ -55,9 +44,6 @@ const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(
|
||||
|
||||
const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw))
|
||||
|
||||
const failIfUnsafe = (name: string, findings: ReadonlyArray<SecretFinding>) =>
|
||||
findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings }))
|
||||
|
||||
export const fileSystem = (
|
||||
options: { readonly directory?: string } = {},
|
||||
): Layer.Layer<Service, never, FileSystem.FileSystem> =>
|
||||
@@ -106,9 +92,11 @@ export const fileSystem = (
|
||||
entry.findings.push(...secretFindings(interaction))
|
||||
const cassette = buildCassette(name, entry.interactions, metadata)
|
||||
const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})]
|
||||
yield* failIfUnsafe(name, findings)
|
||||
yield* ensureDirectory(name)
|
||||
yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie)
|
||||
if (findings.length === 0) {
|
||||
yield* ensureDirectory(name)
|
||||
yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie)
|
||||
}
|
||||
return { findings }
|
||||
}),
|
||||
exists: (name) =>
|
||||
fs.access(cassettePath(name)).pipe(
|
||||
@@ -145,17 +133,17 @@ export const memory = (initial: Record<string, ReadonlyArray<Interaction>> = {})
|
||||
stored.has(name)
|
||||
? Effect.succeed(stored.get(name) ?? [])
|
||||
: Effect.fail(new CassetteNotFoundError({ cassetteName: name })),
|
||||
append: (name, interaction, metadata) => {
|
||||
const existing = stored.get(name)
|
||||
if (existing) existing.push(interaction)
|
||||
else stored.set(name, [interaction])
|
||||
const existingFindings = accumulatedFindings.get(name)
|
||||
const findings = existingFindings ?? []
|
||||
if (!existingFindings) accumulatedFindings.set(name, findings)
|
||||
findings.push(...secretFindings(interaction))
|
||||
if (metadata) findings.push(...secretFindings({ name, ...metadata }))
|
||||
return failIfUnsafe(name, findings)
|
||||
},
|
||||
append: (name, interaction, metadata) =>
|
||||
Effect.sync(() => {
|
||||
const existing = stored.get(name)
|
||||
if (existing) existing.push(interaction)
|
||||
else stored.set(name, [interaction])
|
||||
const findings = accumulatedFindings.get(name)
|
||||
if (findings) findings.push(...secretFindings(interaction))
|
||||
else accumulatedFindings.set(name, [...secretFindings(interaction)])
|
||||
if (metadata) accumulatedFindings.get(name)!.push(...secretFindings({ name, ...metadata }))
|
||||
return { findings: accumulatedFindings.get(name) ?? [] }
|
||||
}),
|
||||
exists: (name) => Effect.sync(() => stored.has(name)),
|
||||
list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()),
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "effect/unstable/http"
|
||||
import * as CassetteService from "./cassette"
|
||||
import { defaultMatcher, selectSequential, type RequestMatcher } from "./matching"
|
||||
import { makeReplayState, resolveAutoMode } from "./recorder"
|
||||
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder"
|
||||
import { defaults, type Redactor } from "./redactor"
|
||||
import { redactUrl } from "./redaction"
|
||||
import { httpInteractions, type CassetteMetadata, type HttpInteraction, type ResponseSnapshot } from "./schema"
|
||||
@@ -100,11 +100,9 @@ export const recordingLayer = (
|
||||
...captured,
|
||||
}),
|
||||
}
|
||||
yield* cassetteService
|
||||
.append(name, interaction, options.metadata)
|
||||
.pipe(
|
||||
Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(transportError(request, error.message))),
|
||||
)
|
||||
yield* appendOrFail(cassetteService, name, interaction, options.metadata).pipe(
|
||||
Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(transportError(request, error.message))),
|
||||
)
|
||||
return HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(decodeResponseBody(interaction.response), interaction.response),
|
||||
|
||||
@@ -7,9 +7,10 @@ export type {
|
||||
WebSocketFrame,
|
||||
WebSocketInteraction,
|
||||
} from "./schema"
|
||||
export { CassetteNotFoundError, hasCassetteSync, UnsafeCassetteError } from "./cassette"
|
||||
export { CassetteNotFoundError, hasCassetteSync } from "./cassette"
|
||||
export { defaultMatcher, type RequestMatcher } from "./matching"
|
||||
export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction"
|
||||
export { UnsafeCassetteError } from "./recorder"
|
||||
export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./effect"
|
||||
export {
|
||||
makeWebSocketExecutor,
|
||||
|
||||
@@ -36,7 +36,7 @@ export const canonicalSnapshot = (snapshot: RequestSnapshot): string =>
|
||||
export const defaultMatcher: RequestMatcher = (incoming, recorded) =>
|
||||
canonicalSnapshot(incoming) === canonicalSnapshot(recorded)
|
||||
|
||||
export const safeText = (value: unknown) => {
|
||||
const safeText = (value: unknown) => {
|
||||
if (value === undefined) return "undefined"
|
||||
if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
|
||||
const text = JSON.stringify(value)
|
||||
|
||||
@@ -1,22 +1,47 @@
|
||||
import { Effect, Ref, Scope } from "effect"
|
||||
import { Effect, Ref, Schema, Scope } from "effect"
|
||||
import type * as CassetteService from "./cassette"
|
||||
import type { CassetteNotFoundError } from "./cassette"
|
||||
import type { Interaction } from "./schema"
|
||||
import { SecretFindingSchema } from "./redaction"
|
||||
import type { CassetteMetadata, Interaction } from "./schema"
|
||||
|
||||
export class UnsafeCassetteError extends Schema.TaggedErrorClass<UnsafeCassetteError>()("UnsafeCassetteError", {
|
||||
cassetteName: Schema.String,
|
||||
findings: Schema.Array(SecretFindingSchema),
|
||||
}) {
|
||||
override get message() {
|
||||
return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings
|
||||
.map((finding) => `${finding.path} (${finding.reason})`)
|
||||
.join(", ")}`
|
||||
}
|
||||
}
|
||||
|
||||
export type ResolvedMode = "record" | "replay" | "passthrough"
|
||||
|
||||
const isCI = () => {
|
||||
const value = process.env.CI
|
||||
return value !== undefined && value !== "" && value !== "false" && value !== "0"
|
||||
}
|
||||
|
||||
export const resolveAutoMode = (
|
||||
cassette: CassetteService.Interface,
|
||||
name: string,
|
||||
): Effect.Effect<"record" | "replay" | "passthrough"> =>
|
||||
export const resolveAutoMode = (cassette: CassetteService.Interface, name: string): Effect.Effect<ResolvedMode> =>
|
||||
Effect.gen(function* () {
|
||||
if (isCI()) return "replay"
|
||||
return (yield* cassette.exists(name)) ? "replay" : "record"
|
||||
})
|
||||
|
||||
export const appendOrFail = (
|
||||
cassette: CassetteService.Interface,
|
||||
name: string,
|
||||
interaction: Interaction,
|
||||
metadata: CassetteMetadata | undefined,
|
||||
): Effect.Effect<void, UnsafeCassetteError> =>
|
||||
cassette
|
||||
.append(name, interaction, metadata)
|
||||
.pipe(
|
||||
Effect.flatMap(({ findings }) =>
|
||||
findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings })),
|
||||
),
|
||||
)
|
||||
|
||||
export interface ReplayState<T> {
|
||||
readonly load: Effect.Effect<ReadonlyArray<T>, CassetteNotFoundError>
|
||||
readonly cursor: Effect.Effect<number>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const REDACTED = "[REDACTED]"
|
||||
|
||||
const DEFAULT_REDACT_HEADERS = [
|
||||
@@ -97,6 +95,8 @@ export const redactHeaders = (
|
||||
)
|
||||
}
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const SecretFindingSchema = Schema.Struct({
|
||||
path: Schema.String,
|
||||
reason: Schema.String,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Effect, Option, Ref, Scope, Stream } from "effect"
|
||||
import type { Headers } from "effect/unstable/http"
|
||||
import * as CassetteService from "./cassette"
|
||||
import { canonicalizeJson, decodeJson, safeText } from "./matching"
|
||||
import { makeReplayState, resolveAutoMode } from "./recorder"
|
||||
import { canonicalizeJson, decodeJson } from "./matching"
|
||||
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder"
|
||||
import type { RecordReplayMode } from "./effect"
|
||||
import { redactUrl } from "./redaction"
|
||||
import { defaults, type Redactor } from "./redactor"
|
||||
import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame } from "./schema"
|
||||
|
||||
@@ -54,7 +53,7 @@ const decodeFrameText = (frame: WebSocketFrame) =>
|
||||
const assertEqual = (message: string, actual: unknown, expected: unknown) =>
|
||||
Effect.sync(() => {
|
||||
if (JSON.stringify(actual) === JSON.stringify(expected)) return
|
||||
throw new Error(`${message}: expected ${safeText(expected)}, received ${safeText(actual)}`)
|
||||
throw new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`)
|
||||
})
|
||||
|
||||
const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson })
|
||||
@@ -62,7 +61,7 @@ const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone:
|
||||
const compareClientMessage = (actual: string, expected: WebSocketFrame | undefined, index: number, asJson: boolean) => {
|
||||
if (!expected)
|
||||
return Effect.sync(() => {
|
||||
throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${safeText(actual)}`)
|
||||
throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${actual}`)
|
||||
})
|
||||
const expectedText = decodeFrameText(expected)
|
||||
if (!asJson) return assertEqual(`WebSocket client frame ${index + 1}`, actual, expectedText)
|
||||
@@ -99,13 +98,12 @@ export const makeWebSocketExecutor = <E>(
|
||||
const closeOnce = Effect.gen(function* () {
|
||||
if (yield* Ref.getAndSet(closed, true)) return
|
||||
yield* connection.close
|
||||
yield* options.cassette
|
||||
.append(
|
||||
options.name,
|
||||
{ transport: "websocket", open: openSnapshot(request), client, server },
|
||||
options.metadata,
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
yield* appendOrFail(
|
||||
options.cassette,
|
||||
options.name,
|
||||
{ transport: "websocket", open: openSnapshot(request), client, server },
|
||||
options.metadata,
|
||||
).pipe(Effect.orDie)
|
||||
})
|
||||
return {
|
||||
sendText: (message) =>
|
||||
@@ -113,7 +111,10 @@ export const makeWebSocketExecutor = <E>(
|
||||
.sendText(message)
|
||||
.pipe(Effect.tap(() => Effect.sync(() => client.push(encodeFrame(message))))),
|
||||
messages: connection.messages.pipe(
|
||||
Stream.tap((message) => Effect.sync(() => server.push(encodeFrame(message)))),
|
||||
Stream.map((message) => {
|
||||
server.push(encodeFrame(message))
|
||||
return message
|
||||
}),
|
||||
),
|
||||
close: closeOnce,
|
||||
}
|
||||
@@ -129,22 +130,20 @@ export const makeWebSocketExecutor = <E>(
|
||||
const interactions = yield* replay.load.pipe(Effect.orDie)
|
||||
const index = yield* replay.cursor
|
||||
const interaction = interactions[index]
|
||||
if (!interaction)
|
||||
return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${redactUrl(request.url)}`))
|
||||
if (!interaction) return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${request.url}`))
|
||||
yield* replay.advance
|
||||
yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request), interaction.open)
|
||||
const messageIndex = yield* Ref.make(0)
|
||||
return {
|
||||
sendText: (message) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Ref.get(messageIndex)
|
||||
const current = yield* Ref.getAndUpdate(messageIndex, (value) => value + 1)
|
||||
yield* compareClientMessage(
|
||||
message,
|
||||
interaction.client[current],
|
||||
current,
|
||||
options.compareClientMessagesAsJson === true,
|
||||
)
|
||||
yield* Ref.update(messageIndex, (value) => value + 1)
|
||||
}),
|
||||
messages: Stream.fromIterable(interaction.server).pipe(Stream.map(decodeFrameMessage)),
|
||||
close: Effect.gen(function* () {
|
||||
|
||||
@@ -323,118 +323,4 @@ describe("http-recorder", () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("auto mode records to disk when the cassette is missing", async () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-record-"))
|
||||
using server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: () => new Response('{"reply":"recorded"}', { headers: { "content-type": "application/json" } }),
|
||||
})
|
||||
const url = `http://127.0.0.1:${server.port}/echo`
|
||||
// CI=true forces replay; clear it so we exercise the local-dev auto-record path.
|
||||
const previous = process.env.CI
|
||||
delete process.env.CI
|
||||
try {
|
||||
const result = await runWith("auto-record", { directory, mode: "auto" }, post(url, { step: 1 }))
|
||||
expect(result).toBe('{"reply":"recorded"}')
|
||||
expect(fs.existsSync(path.join(directory, "auto-record.json"))).toBe(true)
|
||||
} finally {
|
||||
if (previous !== undefined) process.env.CI = previous
|
||||
}
|
||||
})
|
||||
|
||||
test("passthrough mode bypasses the recorder entirely", async () => {
|
||||
using server = Bun.serve({ port: 0, fetch: () => new Response("from-upstream") })
|
||||
const url = `http://127.0.0.1:${server.port}/path`
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-passthrough-"))
|
||||
|
||||
const result = await runWith("passthrough-noop", { directory, mode: "passthrough" }, post(url, {}))
|
||||
expect(result).toBe("from-upstream")
|
||||
expect(fs.existsSync(path.join(directory, "passthrough-noop.json"))).toBe(false)
|
||||
})
|
||||
|
||||
test("UnsafeCassetteError fails the request when a recording would write a known secret", async () => {
|
||||
using server = Bun.serve({ port: 0, fetch: () => new Response("Bearer abcdefghijklmnopqrstuvwxyz1234") })
|
||||
const url = `http://127.0.0.1:${server.port}/leaky`
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-unsafe-"))
|
||||
|
||||
const exit = await Effect.runPromise(
|
||||
Effect.exit(
|
||||
post(url, { ok: true }).pipe(
|
||||
Effect.provide(HttpRecorder.cassetteLayer("unsafe-record", { directory, mode: "record" })),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
expect(failureText(exit)).toContain("contains possible secrets")
|
||||
expect(fs.existsSync(path.join(directory, "unsafe-record.json"))).toBe(false)
|
||||
})
|
||||
|
||||
test("Cassette.list enumerates recorded cassette names", async () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-list-"))
|
||||
await seedCassetteDirectory(directory, "alpha/one", [
|
||||
{
|
||||
transport: "http",
|
||||
request: { method: "GET", url: "https://x.test/a", headers: {}, body: "" },
|
||||
response: { status: 200, headers: {}, body: "a" },
|
||||
},
|
||||
])
|
||||
await seedCassetteDirectory(directory, "beta", [
|
||||
{
|
||||
transport: "http",
|
||||
request: { method: "GET", url: "https://x.test/b", headers: {}, body: "" },
|
||||
response: { status: 200, headers: {}, body: "b" },
|
||||
},
|
||||
])
|
||||
|
||||
const names = await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const cassette = yield* HttpRecorder.Cassette.Service
|
||||
return yield* cassette.list()
|
||||
}).pipe(Effect.provide(HttpRecorder.Cassette.fileSystem({ directory })), Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
expect(names).toEqual(["alpha/one", "beta"])
|
||||
})
|
||||
|
||||
test("WebSocket replay decodes binary frames recorded as base64", async () => {
|
||||
const binaryServer = new Uint8Array([1, 2, 3, 4])
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const cassette = yield* HttpRecorder.Cassette.Service
|
||||
const executor = yield* HttpRecorder.makeWebSocketExecutor({
|
||||
name: "ws/binary",
|
||||
cassette,
|
||||
live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
|
||||
})
|
||||
const connection = yield* executor.open({
|
||||
url: "wss://example.test/binary",
|
||||
headers: Headers.fromInput({}),
|
||||
})
|
||||
const messages: Array<string | Uint8Array> = []
|
||||
yield* connection.messages.pipe(Stream.runForEach((m) => Effect.sync(() => messages.push(m))))
|
||||
yield* connection.close
|
||||
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0]).toBeInstanceOf(Uint8Array)
|
||||
expect(Array.from(messages[0] as Uint8Array)).toEqual([1, 2, 3, 4])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
HttpRecorder.Cassette.memory({
|
||||
"ws/binary": [
|
||||
{
|
||||
transport: "websocket",
|
||||
open: { url: "wss://example.test/binary", headers: {} },
|
||||
client: [],
|
||||
server: [
|
||||
{ kind: "binary", body: Buffer.from(binaryServer).toString("base64"), bodyEncoding: "base64" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.49",
|
||||
"name": "@opencode-ai/llm",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -128,8 +128,17 @@ See `specs/effect/migration.md` for the compact pattern reference and examples.
|
||||
|
||||
Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation rather than storing `Fiber | undefined` or `Promise | undefined` manually. See `specs/effect/migration.md` for the full pattern.
|
||||
|
||||
## Callback boundaries
|
||||
## Instance.bind — ALS for native callbacks
|
||||
|
||||
Use `EffectBridge` for native or external callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, plugin callbacks, etc.) that need to re-enter Effect services with instance/workspace context.
|
||||
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called.
|
||||
|
||||
Plain async code should pass explicit context or stay inside an Effect fiber; do not add ambient instance context shims.
|
||||
Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish` or anything that reads `Instance.directory`.
|
||||
|
||||
You do not need it for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers.
|
||||
|
||||
```typescript
|
||||
const cb = Instance.bind((err, evts) => {
|
||||
Bus.publish(MyEvent, { ... })
|
||||
})
|
||||
nativeAddon.subscribe(dir, cb)
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.49",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -32,6 +32,11 @@
|
||||
"bun": "./src/pty/pty.bun.ts",
|
||||
"node": "./src/pty/pty.node.ts",
|
||||
"default": "./src/pty/pty.bun.ts"
|
||||
},
|
||||
"#httpapi-server": {
|
||||
"bun": "./src/server/httpapi-server.node.ts",
|
||||
"node": "./src/server/httpapi-server.node.ts",
|
||||
"default": "./src/server/httpapi-server.node.ts"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -244,7 +244,6 @@ for (const item of targets) {
|
||||
{
|
||||
name,
|
||||
version: Script.version,
|
||||
preferUnplugged: true,
|
||||
os: [item.os],
|
||||
cpu: [item.arch],
|
||||
},
|
||||
|
||||
@@ -1,189 +1,102 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import childProcess from "child_process"
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { createRequire } from "module"
|
||||
import os from "os"
|
||||
import { fileURLToPath } from "url"
|
||||
import { createRequire } from "module"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const require = createRequire(import.meta.url)
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8"))
|
||||
|
||||
const platformMap = {
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
win32: "windows",
|
||||
}
|
||||
const archMap = {
|
||||
x64: "x64",
|
||||
arm64: "arm64",
|
||||
arm: "arm",
|
||||
function detectPlatformAndArch() {
|
||||
// Map platform names
|
||||
let platform
|
||||
switch (os.platform()) {
|
||||
case "darwin":
|
||||
platform = "darwin"
|
||||
break
|
||||
case "linux":
|
||||
platform = "linux"
|
||||
break
|
||||
case "win32":
|
||||
platform = "windows"
|
||||
break
|
||||
default:
|
||||
platform = os.platform()
|
||||
break
|
||||
}
|
||||
|
||||
// Map architecture names
|
||||
let arch
|
||||
switch (os.arch()) {
|
||||
case "x64":
|
||||
arch = "x64"
|
||||
break
|
||||
case "arm64":
|
||||
arch = "arm64"
|
||||
break
|
||||
case "arm":
|
||||
arch = "arm"
|
||||
break
|
||||
default:
|
||||
arch = os.arch()
|
||||
break
|
||||
}
|
||||
|
||||
return { platform, arch }
|
||||
}
|
||||
|
||||
const platform = platformMap[os.platform()] ?? os.platform()
|
||||
const arch = archMap[os.arch()] ?? os.arch()
|
||||
const base = `opencode-${platform}-${arch}`
|
||||
const sourceBinary = platform === "windows" ? "opencode.exe" : "opencode"
|
||||
const targetBinary = path.join(__dirname, "bin", "opencode.exe")
|
||||
function findBinary() {
|
||||
const { platform, arch } = detectPlatformAndArch()
|
||||
const packageName = `opencode-${platform}-${arch}`
|
||||
const binaryName = platform === "windows" ? "opencode.exe" : "opencode"
|
||||
|
||||
function supportsAvx2() {
|
||||
if (arch !== "x64") return false
|
||||
try {
|
||||
// Use require.resolve to find the package
|
||||
const packageJsonPath = require.resolve(`${packageName}/package.json`)
|
||||
const packageDir = path.dirname(packageJsonPath)
|
||||
const binaryPath = path.join(packageDir, "bin", binaryName)
|
||||
|
||||
if (platform === "linux") {
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
throw new Error(`Binary not found at ${binaryPath}`)
|
||||
}
|
||||
|
||||
return { binaryPath, binaryName }
|
||||
} catch (error) {
|
||||
throw new Error(`Could not find package ${packageName}: ${error.message}`, { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
if (os.platform() === "win32") {
|
||||
// On Windows, the .exe is already included in the package and bin field points to it
|
||||
// No postinstall setup needed
|
||||
console.log("Windows detected: binary setup not needed (using packaged .exe)")
|
||||
return
|
||||
}
|
||||
|
||||
// On non-Windows platforms, just verify the binary package exists
|
||||
// Don't replace the wrapper script - it handles binary execution
|
||||
const { binaryPath } = findBinary()
|
||||
const target = path.join(__dirname, "bin", ".opencode")
|
||||
if (fs.existsSync(target)) fs.unlinkSync(target)
|
||||
try {
|
||||
return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8"))
|
||||
fs.linkSync(binaryPath, target)
|
||||
} catch {
|
||||
return false
|
||||
fs.copyFileSync(binaryPath, target)
|
||||
}
|
||||
fs.chmodSync(target, 0o755)
|
||||
} catch (error) {
|
||||
console.error("Failed to setup opencode binary:", error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (platform === "darwin") {
|
||||
try {
|
||||
const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], {
|
||||
encoding: "utf8",
|
||||
timeout: 1500,
|
||||
})
|
||||
if (result.status !== 0) return false
|
||||
return (result.stdout || "").trim() === "1"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === "windows") {
|
||||
const command =
|
||||
'(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)'
|
||||
|
||||
for (const executable of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) {
|
||||
try {
|
||||
const result = childProcess.spawnSync(executable, ["-NoProfile", "-NonInteractive", "-Command", command], {
|
||||
encoding: "utf8",
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
})
|
||||
if (result.status !== 0) continue
|
||||
const output = (result.stdout || "").trim().toLowerCase()
|
||||
if (output === "true" || output === "1") return true
|
||||
if (output === "false" || output === "0") return false
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function isMusl() {
|
||||
if (platform !== "linux") return false
|
||||
|
||||
try {
|
||||
if (fs.existsSync("/etc/alpine-release")) return true
|
||||
} catch {
|
||||
// Ignore filesystem probes that are blocked by the host.
|
||||
}
|
||||
|
||||
try {
|
||||
const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" })
|
||||
return `${result.stdout || ""}${result.stderr || ""}`.toLowerCase().includes("musl")
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function packageNames() {
|
||||
const baseline = arch === "x64" && !supportsAvx2()
|
||||
|
||||
if (platform === "linux") {
|
||||
if (isMusl()) {
|
||||
if (arch === "x64")
|
||||
return baseline
|
||||
? [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base]
|
||||
: [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`]
|
||||
return [`${base}-musl`, base]
|
||||
}
|
||||
|
||||
if (arch === "x64")
|
||||
return baseline
|
||||
? [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`]
|
||||
: [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`]
|
||||
return [base, `${base}-musl`]
|
||||
}
|
||||
|
||||
if (arch === "x64") return baseline ? [`${base}-baseline`, base] : [base, `${base}-baseline`]
|
||||
return [base]
|
||||
}
|
||||
|
||||
function resolveBinary(name) {
|
||||
const packageJsonPath = require.resolve(`${name}/package.json`)
|
||||
const binaryPath = path.join(path.dirname(packageJsonPath), "bin", sourceBinary)
|
||||
if (!fs.existsSync(binaryPath)) throw new Error(`Binary not found at ${binaryPath}`)
|
||||
return binaryPath
|
||||
}
|
||||
|
||||
function installPackage(name) {
|
||||
const version = packageJson.optionalDependencies?.[name]
|
||||
if (!version) return
|
||||
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-install-"))
|
||||
try {
|
||||
const result = childProcess.spawnSync(
|
||||
"npm",
|
||||
["install", "--ignore-scripts", "--no-save", "--loglevel=error", "--prefix", temp, `${name}@${version}`],
|
||||
{ stdio: "inherit", windowsHide: true },
|
||||
)
|
||||
if (result.status !== 0) return
|
||||
const packageDir = path.join(temp, "node_modules", name)
|
||||
copyBinary(path.join(packageDir, "bin", sourceBinary), targetBinary)
|
||||
return true
|
||||
} finally {
|
||||
fs.rmSync(temp, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function copyBinary(source, target) {
|
||||
if (!fs.existsSync(source)) throw new Error(`Binary not found at ${source}`)
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true })
|
||||
if (fs.existsSync(target)) fs.unlinkSync(target)
|
||||
try {
|
||||
fs.linkSync(source, target)
|
||||
} catch {
|
||||
fs.copyFileSync(source, target)
|
||||
}
|
||||
fs.chmodSync(target, 0o755)
|
||||
}
|
||||
|
||||
function verifyBinary() {
|
||||
const result = childProcess.spawnSync(targetBinary, ["--version"], {
|
||||
encoding: "utf8",
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
})
|
||||
return result.status === 0
|
||||
}
|
||||
|
||||
function main() {
|
||||
for (const name of packageNames()) {
|
||||
try {
|
||||
copyBinary(resolveBinary(name), targetBinary)
|
||||
if (verifyBinary()) return
|
||||
} catch {
|
||||
if (installPackage(name) && verifyBinary()) return
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`It seems your package manager failed to install the right opencode CLI package. Try manually installing ${packageNames()
|
||||
.map((name) => JSON.stringify(name))
|
||||
.join(" or ")}.`,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
main()
|
||||
void main()
|
||||
} catch (error) {
|
||||
console.error(error.message)
|
||||
process.exit(1)
|
||||
console.error("Postinstall script error:", error.message)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
@@ -32,32 +32,22 @@ console.log("binaries", binaries)
|
||||
const version = Object.values(binaries)[0]
|
||||
|
||||
await $`mkdir -p ./dist/${pkg.name}`
|
||||
await $`mkdir -p ./dist/${pkg.name}/bin`
|
||||
await $`cp -r ./bin ./dist/${pkg.name}/bin`
|
||||
await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs`
|
||||
await Bun.file(`./dist/${pkg.name}/LICENSE`).write(await Bun.file("../../LICENSE").text())
|
||||
await Bun.file(`./dist/${pkg.name}/bin/${pkg.name}.exe`).write(
|
||||
[
|
||||
"#!/usr/bin/env node",
|
||||
"console.error('The opencode native binary was not installed. Run `node postinstall.mjs` from the opencode-ai package directory to finish setup.')",
|
||||
"process.exit(1)",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
await Bun.file(`./dist/${pkg.name}/package.json`).write(
|
||||
JSON.stringify(
|
||||
{
|
||||
name: pkg.name + "-ai",
|
||||
bin: {
|
||||
[pkg.name]: `./bin/${pkg.name}.exe`,
|
||||
[pkg.name]: `./bin/${pkg.name}`,
|
||||
},
|
||||
scripts: {
|
||||
postinstall: "node ./postinstall.mjs",
|
||||
postinstall: "bun ./postinstall.mjs || node ./postinstall.mjs",
|
||||
},
|
||||
version: version,
|
||||
license: pkg.license,
|
||||
os: ["darwin", "linux", "win32"],
|
||||
cpu: ["arm64", "x64"],
|
||||
optionalDependencies: binaries,
|
||||
},
|
||||
null,
|
||||
|
||||
@@ -6,6 +6,7 @@ Current status on this branch:
|
||||
|
||||
- `src/` has 5 `makeRuntime(...)` call sites total.
|
||||
- 2 are intentionally excluded from this checklist: `src/bus/index.ts` and `src/effect/cross-spawn-spawner.ts`.
|
||||
- 1 is tracked primarily by the instance-context migration rather than facade removal: `src/project/instance.ts`.
|
||||
- That leaves 2 live runtime-backed service facades still worth tracking here: `src/npm/index.ts` and `src/cli/cmd/tui/config/tui.ts`.
|
||||
|
||||
Recent progress:
|
||||
@@ -17,6 +18,7 @@ Recent progress:
|
||||
|
||||
- `src/cli/cmd/tui/config/tui.ts` still exports `makeRuntime(...)` plus async facade helpers for `get()` and `waitForDependencies()`.
|
||||
- `src/npm/index.ts` still exports `makeRuntime(...)` plus async facade helpers for `install()`, `add()`, `outdated()`, and `which()`.
|
||||
- `src/project/instance.ts` still uses a dedicated runtime for project boot, but that file is really part of the broader legacy instance-context transition tracked in `instance-context.md`.
|
||||
|
||||
## Completed Batches
|
||||
|
||||
@@ -190,6 +192,7 @@ Most of the original facade-removal backlog is already done. The practical remai
|
||||
|
||||
1. remove the `Npm` runtime-backed facade from `src/npm/index.ts`
|
||||
2. remove the `TuiConfig` runtime-backed facade from `src/cli/cmd/tui/config/tui.ts`
|
||||
3. keep `src/project/instance.ts` in the separate instance-context migration, not this checklist
|
||||
|
||||
## Checklist
|
||||
|
||||
|
||||
@@ -197,9 +197,13 @@ For background loops, use `Effect.repeat` or `Effect.schedule` with
|
||||
|
||||
[`EffectBridge`](../../src/effect/bridge.ts) is the sanctioned helper for
|
||||
Promise/callback interop that needs to preserve instance/workspace context.
|
||||
It preserves explicit `InstanceRef` / `WorkspaceRef` context for effects run
|
||||
through the bridge. Plain JS callbacks that need instance data should receive
|
||||
that data explicitly.
|
||||
Keep it, but reduce its dependency on legacy `Instance.current` /
|
||||
`Instance.restore` over time.
|
||||
|
||||
`Instance.bind` / `Instance.restore` are transitional legacy tools. Use
|
||||
them only for native callbacks that still require legacy ALS context. Do
|
||||
not use them for `setTimeout`, `Promise.then`, `EventEmitter.on`, or
|
||||
Effect fibers.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -1,13 +1,309 @@
|
||||
# Instance Context
|
||||
# Instance context migration
|
||||
|
||||
Instance selection is now Effect-provided context.
|
||||
Practical plan for retiring the promise-backed / ALS-backed `Instance` helper in `src/project/instance.ts` and moving instance selection fully into Effect-provided scope.
|
||||
|
||||
Use these APIs:
|
||||
## Goal
|
||||
|
||||
- `InstanceRef` for the current project context.
|
||||
- `WorkspaceRef` for the current workspace id.
|
||||
- `InstanceState.context` / `InstanceState.directory` inside Effect services that require an instance.
|
||||
- `InstanceStore` at entry boundaries that need to load, reload, or dispose project contexts.
|
||||
- `EffectBridge` for native, plugin, or plain JavaScript callback boundaries that need to re-enter Effect with captured refs.
|
||||
End state:
|
||||
|
||||
Do not add new ambient instance globals. Promise and callback boundaries should either stay in Effect, use `EffectBridge`, or pass the required context explicitly.
|
||||
- request, CLI, TUI, and tool entrypoints shift into an instance through Effect, not `Instance.provide(...)`
|
||||
- Effect code reads the current instance from `InstanceRef` or its eventual replacement, not from ALS-backed sync getters
|
||||
- per-directory boot, caching, and disposal are scoped Effect resources, not a module-level `Map<string, Promise<InstanceContext>>`
|
||||
- ALS remains only as a temporary bridge for native callback APIs that fire outside the Effect fiber tree
|
||||
|
||||
## Current split
|
||||
|
||||
Today `src/project/instance.ts` still owns two separate concerns:
|
||||
|
||||
- ambient current-instance context through `LocalContext` / `AsyncLocalStorage`
|
||||
- per-directory boot and deduplication through `cache: Map<string, Promise<InstanceContext>>`
|
||||
|
||||
At the same time, the Effect side already exists:
|
||||
|
||||
- `src/effect/instance-ref.ts` provides `InstanceRef` and `WorkspaceRef`
|
||||
- `src/effect/run-service.ts` already attaches those refs when a runtime starts inside an active instance ALS context
|
||||
- `src/effect/instance-state.ts` already prefers `InstanceRef` and only falls back to ALS when needed
|
||||
|
||||
That means the migration is not "invent instance context in Effect". The migration is "stop relying on the legacy helper as the primary source of truth".
|
||||
|
||||
## End state shape
|
||||
|
||||
Near-term target shape:
|
||||
|
||||
```ts
|
||||
InstanceScope.with({ directory, workspaceID }, effect)
|
||||
```
|
||||
|
||||
Responsibilities of `InstanceScope.with(...)`:
|
||||
|
||||
- resolve `directory`, `project`, and `worktree`
|
||||
- acquire or reuse the scoped per-directory instance environment
|
||||
- provide `InstanceRef` and `WorkspaceRef`
|
||||
- run the caller's Effect inside that environment
|
||||
|
||||
Code inside the boundary should then do one of these:
|
||||
|
||||
```ts
|
||||
const ctx = yield * InstanceState.context
|
||||
const dir = yield * InstanceState.directory
|
||||
```
|
||||
|
||||
Long-term, once `InstanceState` itself is replaced by keyed layers / `LayerMap`, those reads can move to an `InstanceContext` service without changing the outer migration order.
|
||||
|
||||
## Migration phases
|
||||
|
||||
### Phase 1: stop expanding the legacy surface
|
||||
|
||||
Rules for all new code:
|
||||
|
||||
- do not add new `Instance.directory`, `Instance.worktree`, `Instance.project`, or `Instance.current` reads inside Effect code
|
||||
- do not add new `Instance.provide(...)` boundaries unless there is no Effect-native seam yet
|
||||
- use `InstanceState.context`, `InstanceState.directory`, or an explicit `ctx` parameter inside Effect code
|
||||
|
||||
Success condition:
|
||||
|
||||
- the file inventory below only shrinks from here
|
||||
|
||||
### Phase 2: remove direct sync getter reads from Effect services
|
||||
|
||||
Convert Effect services first, before replacing the top-level boundary. These modules already run inside Effect and mostly need `yield* InstanceState.context` or a yielded `ctx` instead of ambient sync access.
|
||||
|
||||
Primary batch, highest payoff:
|
||||
|
||||
- `src/file/index.ts`
|
||||
- `src/lsp/server.ts`
|
||||
- `src/worktree/index.ts`
|
||||
- `src/file/watcher.ts`
|
||||
- `src/format/formatter.ts`
|
||||
- `src/session/index.ts`
|
||||
- `src/project/vcs.ts`
|
||||
|
||||
Mechanical replacement rule:
|
||||
|
||||
- `Instance.directory` -> `ctx.directory` or `yield* InstanceState.directory`
|
||||
- `Instance.worktree` -> `ctx.worktree`
|
||||
- `Instance.project` -> `ctx.project`
|
||||
|
||||
Do not thread strings manually through every public method if the service already has access to Effect context.
|
||||
|
||||
### Phase 3: convert entry boundaries to provide instance refs directly
|
||||
|
||||
After the service bodies stop assuming ALS, move the top-level boundaries to shift into Effect explicitly.
|
||||
|
||||
Main boundaries:
|
||||
|
||||
- HTTP server middleware and experimental `HttpApi` entrypoints
|
||||
- CLI commands
|
||||
- TUI worker / attach / thread entrypoints
|
||||
- tool execution entrypoints
|
||||
|
||||
These boundaries should become Effect-native wrappers that:
|
||||
|
||||
- decode directory / workspace inputs
|
||||
- resolve the instance context once
|
||||
- provide `InstanceRef` and `WorkspaceRef`
|
||||
- run the requested Effect
|
||||
|
||||
At that point `Instance.provide(...)` becomes a legacy adapter instead of the normal code path.
|
||||
|
||||
### Phase 4: replace promise boot cache with scoped instance runtime
|
||||
|
||||
Once boundaries and services both rely on Effect context, replace the module-level promise cache in `src/project/instance.ts`.
|
||||
|
||||
Target replacement:
|
||||
|
||||
- keyed scoped runtime or keyed layer acquisition for each directory
|
||||
- reuse via `ScopedCache`, `LayerMap`, or another keyed Effect resource manager
|
||||
- cleanup performed by scope finalizers instead of `disposeAll()` iterating a Promise map
|
||||
|
||||
This phase should absorb the current responsibilities of:
|
||||
|
||||
- `cache` in `src/project/instance.ts`
|
||||
- `boot(...)`
|
||||
- most of `disposeInstance(...)`
|
||||
- manual `reload(...)` / `disposeAll()` fan-out logic
|
||||
|
||||
### Phase 5: shrink ALS to callback bridges only
|
||||
|
||||
Keep ALS only where a library invokes callbacks outside the Effect fiber tree and we still need to call code that reads instance context synchronously.
|
||||
|
||||
Known bridge cases today:
|
||||
|
||||
- `src/file/watcher.ts`
|
||||
- `src/session/llm.ts`
|
||||
- some LSP and plugin callback paths
|
||||
|
||||
If those libraries become fully wrapped in Effect services, the remaining `Instance.bind(...)` uses can disappear too.
|
||||
|
||||
### Phase 6: delete the legacy sync API
|
||||
|
||||
Only after earlier phases land:
|
||||
|
||||
- remove broad use of `Instance.current`, `Instance.directory`, `Instance.worktree`, `Instance.project`
|
||||
- reduce `src/project/instance.ts` to a thin compatibility shim or delete it entirely
|
||||
- remove the ALS fallback from `InstanceState.context`
|
||||
|
||||
## Inventory of direct legacy usage
|
||||
|
||||
Direct legacy usage means any source file that still calls one of:
|
||||
|
||||
- `Instance.current`
|
||||
- `Instance.directory`
|
||||
- `Instance.worktree`
|
||||
- `Instance.project`
|
||||
- `Instance.provide(...)`
|
||||
- `Instance.bind(...)`
|
||||
- `Instance.restore(...)`
|
||||
- `Instance.reload(...)`
|
||||
- `Instance.dispose()` / `Instance.disposeAll()`
|
||||
|
||||
Current total: `56` files in `packages/opencode/src`.
|
||||
|
||||
### Core bridge and plumbing
|
||||
|
||||
These files define or adapt the current bridge. They should change last, after callers have moved.
|
||||
|
||||
- `src/project/instance.ts`
|
||||
- `src/effect/run-service.ts`
|
||||
- `src/effect/instance-state.ts`
|
||||
- `src/project/bootstrap.ts`
|
||||
- `src/config/config.ts`
|
||||
|
||||
Migration rule:
|
||||
|
||||
- keep these as compatibility glue until the outer boundaries and inner services stop depending on ALS
|
||||
|
||||
### HTTP and server boundaries
|
||||
|
||||
These are the current request-entry seams that still create or consume instance context through the legacy helper.
|
||||
|
||||
- `src/server/routes/instance/middleware.ts`
|
||||
- `src/server/routes/instance/index.ts`
|
||||
- `src/server/routes/instance/project.ts`
|
||||
- `src/server/routes/control/workspace.ts`
|
||||
- `src/server/routes/instance/file.ts`
|
||||
- `src/server/routes/instance/experimental.ts`
|
||||
- `src/server/routes/global.ts`
|
||||
|
||||
Migration rule:
|
||||
|
||||
- move these to explicit Effect entrypoints that provide `InstanceRef` / `WorkspaceRef`
|
||||
- do not move these first; first reduce the number of downstream handlers and services that still expect ambient ALS
|
||||
|
||||
### CLI and TUI boundaries
|
||||
|
||||
These commands still enter an instance through `Instance.provide(...)` or read sync getters directly.
|
||||
|
||||
- `src/cli/bootstrap.ts`
|
||||
- `src/cli/cmd/agent.ts`
|
||||
- `src/cli/cmd/debug/agent.ts`
|
||||
- `src/cli/cmd/debug/ripgrep.ts`
|
||||
- `src/cli/cmd/github.ts`
|
||||
- `src/cli/cmd/import.ts`
|
||||
- `src/cli/cmd/mcp.ts`
|
||||
- `src/cli/cmd/models.ts`
|
||||
- `src/cli/cmd/plug.ts`
|
||||
- `src/cli/cmd/pr.ts`
|
||||
- `src/cli/cmd/providers.ts`
|
||||
- `src/cli/cmd/stats.ts`
|
||||
- `src/cli/cmd/tui/attach.ts`
|
||||
- `src/cli/cmd/tui/plugin/runtime.ts`
|
||||
- `src/cli/cmd/tui/thread.ts`
|
||||
- `src/cli/cmd/tui/worker.ts`
|
||||
|
||||
Migration rule:
|
||||
|
||||
- converge these on one shared `withInstance(...)` Effect entry helper instead of open-coded `Instance.provide(...)`
|
||||
- after that helper is proven, inline the legacy implementation behind an Effect-native scope provider
|
||||
|
||||
### Tool boundary code
|
||||
|
||||
These tools mostly use direct getters for path resolution and repo-relative display logic.
|
||||
|
||||
- `src/tool/apply_patch.ts`
|
||||
- `src/tool/bash.ts`
|
||||
- `src/tool/edit.ts`
|
||||
- `src/tool/lsp.ts`
|
||||
- `src/tool/plan.ts`
|
||||
- `src/tool/read.ts`
|
||||
- `src/tool/write.ts`
|
||||
|
||||
Migration rule:
|
||||
|
||||
- expose the current instance as an explicit Effect dependency for tool execution
|
||||
- keep path logic local; avoid introducing another global singleton for tool state
|
||||
|
||||
### Effect services still reading ambient instance state
|
||||
|
||||
These modules are already the best near-term migration targets because they are in Effect code but still read sync getters from the legacy helper.
|
||||
|
||||
- `src/agent/agent.ts`
|
||||
- `src/cli/cmd/tui/config/tui-migrate.ts`
|
||||
- `src/file/index.ts`
|
||||
- `src/file/watcher.ts`
|
||||
- `src/format/formatter.ts`
|
||||
- `src/lsp/client.ts`
|
||||
- `src/lsp/index.ts`
|
||||
- `src/lsp/server.ts`
|
||||
- `src/mcp/index.ts`
|
||||
- `src/project/vcs.ts`
|
||||
- `src/provider/provider.ts`
|
||||
- `src/pty/index.ts`
|
||||
- `src/session/session.ts`
|
||||
- `src/session/instruction.ts`
|
||||
- `src/session/llm.ts`
|
||||
- `src/session/system.ts`
|
||||
- `src/sync/index.ts`
|
||||
- `src/worktree/index.ts`
|
||||
|
||||
Migration rule:
|
||||
|
||||
- replace direct getter reads with `yield* InstanceState.context` or a yielded `ctx`
|
||||
- isolate `Instance.bind(...)` callers and convert only the truly callback-driven edges to bridge mode
|
||||
|
||||
### Highest-churn hotspots
|
||||
|
||||
Current highest direct-usage counts by file:
|
||||
|
||||
- `src/file/index.ts` - `18`
|
||||
- `src/lsp/server.ts` - `14`
|
||||
- `src/worktree/index.ts` - `12`
|
||||
- `src/file/watcher.ts` - `9`
|
||||
- `src/cli/cmd/mcp.ts` - `8`
|
||||
- `src/format/formatter.ts` - `8`
|
||||
- `src/tool/apply_patch.ts` - `8`
|
||||
- `src/cli/cmd/github.ts` - `7`
|
||||
|
||||
These files should drive the first measurable burn-down.
|
||||
|
||||
## Recommended implementation order
|
||||
|
||||
1. Migrate direct getter reads inside Effect services, starting with `file`, `lsp`, `worktree`, `format`, and `session`.
|
||||
2. Add one shared Effect-native boundary helper for CLI / tool / HTTP entrypoints so we stop open-coding `Instance.provide(...)`.
|
||||
3. Move experimental `HttpApi` entrypoints to that helper so the new server stack proves the pattern.
|
||||
4. Convert remaining CLI and tool boundaries.
|
||||
5. Replace the promise cache with a keyed scoped runtime or keyed layer map.
|
||||
6. Delete ALS fallback paths once only callback bridges still depend on them.
|
||||
|
||||
## Definition of done
|
||||
|
||||
This migration is done when all of the following are true:
|
||||
|
||||
- new requests and commands enter an instance by providing Effect context, not ALS
|
||||
- Effect services no longer read `Instance.directory`, `Instance.worktree`, `Instance.project`, or `Instance.current`
|
||||
- `Instance.provide(...)` is gone from normal request / CLI / tool execution
|
||||
- per-directory boot and disposal are handled by scoped Effect resources
|
||||
- `Instance.bind(...)` is either gone or confined to a tiny set of native callback adapters
|
||||
|
||||
## Tracker and worktree
|
||||
|
||||
Active tracker items:
|
||||
|
||||
- `lh7l73` - overall `HttpApi` migration
|
||||
- `yobwlk` - remove direct `Instance.*` reads inside Effect services
|
||||
- `7irl1e` - replace `InstanceState` / legacy instance caching with keyed Effect layers
|
||||
|
||||
Dedicated worktree for this transition:
|
||||
|
||||
- path: `/Users/kit/code/open-source/opencode-worktrees/instance-effect-shift`
|
||||
- branch: `kit/instance-effect-shift`
|
||||
|
||||
@@ -24,6 +24,10 @@ Small follow-ups that do not fit neatly into the main facade, route, tool, or sc
|
||||
- [ ] `cli/cmd/tui/config/tui.ts` - switch TUI config loading from async `ConfigPaths.*` wrappers to the `ConfigPaths.Service` once that service exists.
|
||||
- [ ] `cli/cmd/tui/config/tui-migrate.ts` - decide whether to leave this as a plain async module using wrapper functions or effectify it fully after `ConfigPaths.Service` lands.
|
||||
|
||||
## Instance cleanup
|
||||
|
||||
- [ ] `project/instance.ts` - keep shrinking the legacy ALS / Promise cache after the remaining `Instance.*` callers move over.
|
||||
|
||||
## Notes
|
||||
|
||||
- Prefer small, semantics-preserving config migrations. Config precedence, legacy key migration, and plugin origin tracking are easy to break accidentally.
|
||||
|
||||
@@ -64,11 +64,13 @@ P6 OA
|
||||
explicit and testable instead of mutable module state.
|
||||
Shrinks: [`global.ts`](../../../core/src/global.ts) import-time side
|
||||
effects, mutable `Global.Path` overrides, and its `Flag` dependency.
|
||||
- `INST` Instance context — keep project context explicit through Effect refs
|
||||
and bridge boundaries.
|
||||
- `INST` Instance shim — remove ambient `Instance` usage and old ALS
|
||||
access patterns.
|
||||
Shrinks: [`src/project/instance.ts`](../../src/project/instance.ts).
|
||||
- `BRIDGE` Promise/callback interop — keep bridge helpers, but reduce
|
||||
legacy ALS coupling.
|
||||
Shrinks: ad hoc Promise/callback re-entry code.
|
||||
Shrinks: [`src/effect/bridge.ts`](../../src/effect/bridge.ts)
|
||||
dependency on [`project/instance.ts`](../../src/project/instance.ts).
|
||||
- `PROC` AppProcess migration — prefer `AppProcess.Service` over raw
|
||||
process wrappers.
|
||||
Shrinks: direct spawn callsites and legacy process helpers.
|
||||
@@ -92,16 +94,19 @@ shapes and sometimes collapse rich errors into opaque strings.
|
||||
|
||||
### Problems
|
||||
|
||||
- Some expected service failures still use `NamedError.create(...)` or
|
||||
collapse to `Effect.die(...)`. The storage/worktree/provider-auth
|
||||
conversions are done; an inventory sweep is needed for the rest.
|
||||
- HTTP error middleware still guesses status codes from error names —
|
||||
some entries (e.g. storage `NotFound`, provider auth) can now be
|
||||
removed, but the middleware overall has not shrunk.
|
||||
- Some expected service failures still use `NamedError.create(...)`.
|
||||
- Some expected service failures still become `Effect.die(...)`, which
|
||||
makes them defects instead of typed, recoverable failures.
|
||||
- CLI and HTTP boundaries can render structured errors as generic
|
||||
`Error: SomeName` output.
|
||||
- HTTP error middleware still guesses status codes from error names like
|
||||
`Worktree*` or `ProviderAuthValidationFailed`.
|
||||
- Route handlers and route groups do not consistently declare the public
|
||||
error body they intend to expose.
|
||||
- Repeated route error translations do not yet have a clear home: some
|
||||
should stay inline, some deserve tiny shared mapper helpers.
|
||||
- Unknown 500s should log full detail server-side while returning a safe
|
||||
public body.
|
||||
|
||||
### Target Shape
|
||||
|
||||
@@ -121,33 +126,19 @@ shapes and sometimes collapse rich errors into opaque strings.
|
||||
- Generic HTTP middleware should shrink; it should not accumulate more
|
||||
name-based domain knowledge.
|
||||
|
||||
### Recently completed
|
||||
|
||||
- [x] `RENDER-1` CLI tagged config error rendering (#27256, tests #27257).
|
||||
- [x] `ERR-1` [`storage/storage.ts`](../../src/storage/storage.ts) typed
|
||||
`NotFoundError` (#27265) and removal of the server defect fallback
|
||||
(#27287).
|
||||
- [x] `ERR-2` [`worktree/index.ts`](../../src/worktree/index.ts) typed
|
||||
errors (#27296).
|
||||
- [x] `ERR-3` [`provider/auth.ts`](../../src/provider/auth.ts) typed
|
||||
validation/oauth errors (#27301).
|
||||
- [x] `HTTP-1` Unknown-500 details no longer leaked (#27251); follow-up
|
||||
to stop exposing named defects (#27471).
|
||||
- [x] Session message reads typed and made effectful (#27269, #27275,
|
||||
#27280, #27291).
|
||||
- [x] Session HTTP error contracts tightened (#27308); busy-session
|
||||
mapping centralized (#27375, #27473).
|
||||
- [x] Provider init (#27484) and LSP init (#27494) errors typed.
|
||||
|
||||
### First PR Candidates
|
||||
|
||||
- [ ] `RENDER-1` Fix CLI top-level rendering for typed config errors.
|
||||
- [ ] `ERR-1` Convert [`storage/storage.ts`](../../src/storage/storage.ts)
|
||||
not-found errors.
|
||||
- [ ] `ERR-2` Convert [`worktree/index.ts`](../../src/worktree/index.ts)
|
||||
errors and remove matching HTTP name checks where possible.
|
||||
- [ ] `ERR-3` Convert [`provider/auth.ts`](../../src/provider/auth.ts)
|
||||
validation errors.
|
||||
- [ ] `HTTP-1` Remove the unknown-500 stack leak from
|
||||
[`middleware/error.ts`](../../src/server/routes/instance/httpapi/middleware/error.ts).
|
||||
- [ ] `HTTP-2` Audit one route group for explicit error contracts and
|
||||
decide which mappings stay inline vs. shared helper.
|
||||
- [ ] `ERR-4` Sweep remaining `NamedError.create(...)` and
|
||||
`Effect.die(...)` callsites for expected failures — re-run `git
|
||||
grep` to build a current inventory.
|
||||
- [ ] `RENDER-2` Audit CLI and TUI surfaces for any remaining opaque
|
||||
`Error: Name` rendering of typed errors.
|
||||
|
||||
## P1: Tests
|
||||
|
||||
@@ -172,24 +163,26 @@ Recently completed:
|
||||
- [x] Built-in websearch provider selection uses the same runtime flags as
|
||||
tool visibility.
|
||||
- [x] Removed global default-plugin disabling from test preload.
|
||||
- [x] `RF-1` Scout reads routed through runtime flags (#27318).
|
||||
- [x] `RF-2` Plan-mode prompt read routed through runtime flags (#27320).
|
||||
- [x] `RF-3` Event-system reads routed through runtime flags (#27323).
|
||||
- [x] `RF-4` Workspaces reads routed through runtime flags for session
|
||||
(#27335), sync (#27336), and control-plane (#27337).
|
||||
- [x] LLM client (#27368) and installation client (#27369) routed
|
||||
through runtime flags.
|
||||
- [x] TUI plugin runtime flags simplified (#27506).
|
||||
- [x] Background-subagents flag moved to RuntimeFlags, then removed
|
||||
(`refactor(task): use runtime flag for background subagents`,
|
||||
`refactor(flags): remove background subagents flag`).
|
||||
|
||||
Remaining cleanup:
|
||||
Recommended next PRs:
|
||||
|
||||
- [ ] Sweep lingering `Flag.*` reads — many CLI/TUI/config/observability
|
||||
callsites still import [`flag.ts`](../../../core/src/flag/flag.ts).
|
||||
Decide per-callsite whether to route through RuntimeFlags, accept
|
||||
as legitimate env/config boundary, or migrate to typed `Config`.
|
||||
```text
|
||||
RF-1 scout consumers ─┐
|
||||
├─ can run in parallel
|
||||
RF-2 plan-mode prompt ┘
|
||||
└─ RF-3 event-system cluster, stacked only if RF-2 still touches prompt.ts
|
||||
|
||||
RF-4 workspaces cluster: later, after mutable Flag tests are cleaned up
|
||||
```
|
||||
|
||||
- [ ] `RF-1` Move scout reads in [`agent.ts`](../../src/agent/agent.ts)
|
||||
and [`reference.ts`](../../src/reference/reference.ts).
|
||||
- [ ] `RF-2` Move plan-mode prompt read in
|
||||
[`session/prompt.ts`](../../src/session/prompt.ts).
|
||||
- [ ] `RF-3` Move event-system reads in session prompt/processor/
|
||||
compaction and TUI debug plugin.
|
||||
- [ ] `RF-4` Move workspaces reads in session/sync/control-plane after
|
||||
tests stop relying on mutable `Flag` timing.
|
||||
- [ ] Delete [`test/fixture/flag.ts`](../../test/fixture/flag.ts) once
|
||||
tests no longer mutate `Flag`.
|
||||
- [ ] Delete [`flag.ts`](../../../core/src/flag/flag.ts) once no packages
|
||||
@@ -219,13 +212,17 @@ Next PR candidates:
|
||||
|
||||
## P4: Instance And Bridge
|
||||
|
||||
Instance context migration is complete for the legacy sync shim. Promise and callback interop continues through [`effect/bridge.ts`](../../src/effect/bridge.ts).
|
||||
[`project/instance.ts`](../../src/project/instance.ts) is the deletion
|
||||
target. [`effect/bridge.ts`](../../src/effect/bridge.ts) is not a near-term
|
||||
deletion target; Promise/callback interop will continue to exist.
|
||||
|
||||
Current rules:
|
||||
Goal:
|
||||
|
||||
- Effect services read instance data from `InstanceRef`, `WorkspaceRef`, `InstanceState`, or explicit arguments.
|
||||
- Plain JavaScript callback boundaries use `EffectBridge` or explicit context arguments.
|
||||
- Runtime entrypoints must provide refs explicitly when they are instance-scoped.
|
||||
- Keep a sanctioned bridge for Promise/callback boundaries.
|
||||
- Reduce bridge dependence on legacy `Instance.restore` / `Instance.current`.
|
||||
- Move callers toward `InstanceRef`, `WorkspaceRef`, `InstanceState`, or
|
||||
explicit context where practical.
|
||||
- Delete `project/instance.ts` only after ambient Instance coupling is gone.
|
||||
|
||||
## Lower Priority Tracks
|
||||
|
||||
|
||||
@@ -39,9 +39,10 @@ import { Filesystem } from "@/util/filesystem"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { ACPSessionManager } from "./session"
|
||||
import type { ACPConfig } from "./types"
|
||||
import { ACPRuntime } from "./runtime"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { Agent as AgentModule } from "../agent/agent"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Installation } from "@/installation"
|
||||
import { MessageV2 } from "@/session/message-v2"
|
||||
import { Config } from "@/config/config"
|
||||
@@ -1093,7 +1094,7 @@ export class Agent implements ACPAgent {
|
||||
|
||||
const currentModeId = await (async () => {
|
||||
if (!availableModes.length) return undefined
|
||||
const defaultAgent = await ACPRuntime.defaultAgentInfo(directory)
|
||||
const defaultAgent = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo()))
|
||||
const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgent.name)?.id ?? availableModes[0].id
|
||||
this.sessionManager.setMode(sessionId, resolvedModeId)
|
||||
return resolvedModeId
|
||||
@@ -1327,7 +1328,8 @@ export class Agent implements ACPAgent {
|
||||
if (!current) {
|
||||
this.sessionManager.setModel(session.id, model)
|
||||
}
|
||||
const agent = session.modeId ?? (await ACPRuntime.defaultAgentInfo(directory)).name
|
||||
const agent =
|
||||
session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo()))).name
|
||||
|
||||
const parts: Array<
|
||||
| { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { AppRuntime, type AppServices } from "@/effect/app-runtime"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { Effect } from "effect"
|
||||
|
||||
// Global ACP Effect re-entry: no project InstanceRef is provided.
|
||||
export const runGlobal = AppRuntime.runPromise
|
||||
|
||||
// Directory-scoped ACP Effect re-entry: load the project instance and provide InstanceRef.
|
||||
export async function runDirectory<A, E>(input: { directory: string; effect: Effect.Effect<A, E, AppServices> }) {
|
||||
const ctx = await InstanceRuntime.load({ directory: input.directory })
|
||||
return AppRuntime.runPromise(input.effect.pipe(Effect.provideService(InstanceRef, ctx)))
|
||||
}
|
||||
|
||||
export const defaultAgentInfo = (directory: string) =>
|
||||
runDirectory({
|
||||
directory,
|
||||
effect: Agent.Service.use((svc) => svc.defaultInfo()),
|
||||
})
|
||||
|
||||
export * as ACPRuntime from "./runtime"
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Schema } from "effect"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
|
||||
export type Definition<Type extends string = string, Properties extends Schema.Top = Schema.Top> = {
|
||||
type: Type
|
||||
@@ -18,28 +17,16 @@ export function define<Type extends string, Properties extends Schema.Top>(
|
||||
}
|
||||
|
||||
export function effectPayloads() {
|
||||
return [
|
||||
...registry
|
||||
.entries()
|
||||
.map(([type, def]) =>
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
type: Schema.Literal(type),
|
||||
properties: def.properties,
|
||||
}).annotate({ identifier: `Event.${type}` }),
|
||||
)
|
||||
.toArray(),
|
||||
...EventV2.registry
|
||||
.values()
|
||||
.map((definition) =>
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
type: Schema.Literal(definition.type),
|
||||
properties: definition.data,
|
||||
}).annotate({ identifier: `Event.${definition.type}` }),
|
||||
)
|
||||
.toArray(),
|
||||
]
|
||||
return registry
|
||||
.entries()
|
||||
.map(([type, def]) =>
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
type: Schema.Literal(type),
|
||||
properties: def.properties,
|
||||
}).annotate({ identifier: `Event.${type}` }),
|
||||
)
|
||||
.toArray()
|
||||
}
|
||||
|
||||
export * as BusEvent from "./bus-event"
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { Instance } from "../project/instance"
|
||||
import { InstanceRuntime } from "../project/instance-runtime"
|
||||
import { context } from "../project/instance-context"
|
||||
import { WithInstance } from "../project/with-instance"
|
||||
|
||||
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
|
||||
const ctx = await InstanceRuntime.load({ directory })
|
||||
try {
|
||||
return await context.provide(ctx, cb)
|
||||
} finally {
|
||||
await InstanceRuntime.disposeInstance(ctx)
|
||||
}
|
||||
return WithInstance.provide({
|
||||
directory,
|
||||
fn: async () => {
|
||||
try {
|
||||
const result = await cb()
|
||||
return result
|
||||
} finally {
|
||||
await InstanceRuntime.disposeInstance(Instance.current)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ const QueryCommand = cmd({
|
||||
handler: async (args: { query?: string; format: string }) => {
|
||||
const query = args.query as string | undefined
|
||||
if (query) {
|
||||
const db = new BunDatabase(Database.getPath(), { readonly: true })
|
||||
const db = new BunDatabase(Database.Path, { readonly: true })
|
||||
try {
|
||||
const result = db.query(query).all() as Record<string, unknown>[]
|
||||
if (args.format === "json") {
|
||||
@@ -47,7 +47,7 @@ const QueryCommand = cmd({
|
||||
db.close()
|
||||
return
|
||||
}
|
||||
const child = spawn("sqlite3", [Database.getPath()], {
|
||||
const child = spawn("sqlite3", [Database.Path], {
|
||||
stdio: "inherit",
|
||||
})
|
||||
await new Promise((resolve) => child.on("close", resolve))
|
||||
@@ -58,7 +58,7 @@ const PathCommand = cmd({
|
||||
command: "path",
|
||||
describe: "print the database path",
|
||||
handler: () => {
|
||||
console.log(Database.getPath())
|
||||
console.log(Database.Path)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -66,7 +66,7 @@ const MigrateCommand = cmd({
|
||||
command: "migrate",
|
||||
describe: "migrate JSON data to SQLite (merges with existing data)",
|
||||
handler: async () => {
|
||||
const sqlite = new BunDatabase(Database.getPath())
|
||||
const sqlite = new BunDatabase(Database.Path)
|
||||
const tty = process.stderr.isTTY
|
||||
const width = 36
|
||||
const orange = "\x1b[38;5;214m"
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Permission } from "../../../permission"
|
||||
import { iife } from "../../../util/iife"
|
||||
import { effectCmd, fail } from "../../effect-cmd"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
import type { InstanceContext } from "@/project/instance"
|
||||
|
||||
export const AgentCommand = effectCmd({
|
||||
command: "agent <name>",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { InstanceServiceMap } from "@opencode-ai/core/instance-layer"
|
||||
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
|
||||
const Runtime = Layer.mergeAll(LocationServiceMap.layer)
|
||||
const Runtime = Layer.mergeAll(InstanceServiceMap.layer)
|
||||
|
||||
export const V2Command = effectCmd({
|
||||
command: "v2",
|
||||
@@ -37,7 +37,7 @@ export const V2Command = effectCmd({
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + EOL)
|
||||
},
|
||||
Effect.provide(
|
||||
LocationServiceMap.get({
|
||||
InstanceServiceMap.get({
|
||||
directory: process.cwd(),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
||||
|
||||
function promptOffsetWidth(value: string) {
|
||||
let width = 0
|
||||
for (const part of graphemes.segment(value)) {
|
||||
// Textarea offsets count newlines as one position; Bun.stringWidth counts them as zero.
|
||||
width += part.segment === "\n" ? 1 : Bun.stringWidth(part.segment)
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
function displayOffsetIndex(value: string, offset: number) {
|
||||
if (offset <= 0) return 0
|
||||
|
||||
let width = 0
|
||||
for (const part of graphemes.segment(value)) {
|
||||
const next = width + promptOffsetWidth(part.segment)
|
||||
const next = width + Bun.stringWidth(part.segment)
|
||||
if (next > offset) return part.index
|
||||
width = next
|
||||
}
|
||||
@@ -22,20 +13,20 @@ function displayOffsetIndex(value: string, offset: number) {
|
||||
return value.length
|
||||
}
|
||||
|
||||
export function displaySlice(value: string, start = 0, end = promptOffsetWidth(value)) {
|
||||
export function displaySlice(value: string, start = 0, end = Bun.stringWidth(value)) {
|
||||
return value.slice(displayOffsetIndex(value, start), displayOffsetIndex(value, end))
|
||||
}
|
||||
|
||||
export function displayCharAt(value: string, offset: number) {
|
||||
let width = 0
|
||||
for (const part of graphemes.segment(value)) {
|
||||
const next = width + promptOffsetWidth(part.segment)
|
||||
const next = width + Bun.stringWidth(part.segment)
|
||||
if (offset === width || offset < next) return part.segment
|
||||
width = next
|
||||
}
|
||||
}
|
||||
|
||||
export function mentionTriggerIndex(value: string, offset = promptOffsetWidth(value)) {
|
||||
export function mentionTriggerIndex(value: string, offset = Bun.stringWidth(value)) {
|
||||
const text = displaySlice(value, 0, offset)
|
||||
const index = text.lastIndexOf("@")
|
||||
if (index === -1) return
|
||||
@@ -43,6 +34,6 @@ export function mentionTriggerIndex(value: string, offset = promptOffsetWidth(va
|
||||
const before = index === 0 ? undefined : text[index - 1]
|
||||
const query = text.slice(index)
|
||||
if ((before === undefined || /\s/.test(before)) && !/\s/.test(query)) {
|
||||
return promptOffsetWidth(text.slice(0, index))
|
||||
return Bun.stringWidth(text.slice(0, index))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,13 +17,13 @@ import { pathToFileURL } from "url"
|
||||
import { Effect } from "effect"
|
||||
import { UI } from "../ui"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { EOL } from "os"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Permission } from "@/permission"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { FormatError, FormatUnknownError } from "../error"
|
||||
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin"
|
||||
|
||||
@@ -235,7 +235,6 @@ export const RunCommand = effectCmd({
|
||||
}),
|
||||
handler: Effect.fn("Cli.run")(function* (args) {
|
||||
const agentSvc = yield* Agent.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
yield* Effect.promise(async () => {
|
||||
const rawMessage = [...args.message, ...(args["--"] || [])].join(" ")
|
||||
const thinking = args.interactive ? (args.thinking ?? true) : (args.thinking ?? false)
|
||||
@@ -447,7 +446,7 @@ export const RunCommand = effectCmd({
|
||||
async function share(sdk: OpencodeClient, sessionID: string) {
|
||||
const cfg = await sdk.config.get()
|
||||
if (!cfg.data) return
|
||||
if (cfg.data.share !== "auto" && !flags.autoShare && !args.share) return
|
||||
if (cfg.data.share !== "auto" && !Flag.OPENCODE_AUTO_SHARE && !args.share) return
|
||||
const res = await sdk.session.share({ sessionID }).catch((error) => {
|
||||
if (error instanceof Error && error.message.includes("disabled")) {
|
||||
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
|
||||
|
||||
@@ -76,6 +76,8 @@ const appBindingCommands = [
|
||||
"command.palette.show",
|
||||
"session.list",
|
||||
"session.new",
|
||||
"session.cycle_recent",
|
||||
"session.cycle_recent_reverse",
|
||||
"session.quick_switch.1",
|
||||
"session.quick_switch.2",
|
||||
"session.quick_switch.3",
|
||||
@@ -479,15 +481,37 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
name: `session.quick_switch.${i + 1}`,
|
||||
title: `Switch to session in quick slot ${i + 1}`,
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
local.session.quickSwitch(i + 1)
|
||||
},
|
||||
})),
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
|
||||
? [
|
||||
{
|
||||
name: "session.cycle_recent",
|
||||
title: "Cycle to previous recent session",
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
local.session.cycleRecent(1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "session.cycle_recent_reverse",
|
||||
title: "Cycle to next recent session",
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
local.session.cycleRecent(-1)
|
||||
},
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
name: `session.quick_switch.${i + 1}`,
|
||||
title: `Switch to session in quick slot ${i + 1}`,
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
local.session.quickSwitch(i + 1)
|
||||
},
|
||||
})),
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "model.list",
|
||||
title: "Switch model",
|
||||
@@ -802,7 +826,14 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
|
||||
useBindings(() => ({
|
||||
enabled: command.matcher,
|
||||
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
|
||||
bindings: tuiConfig.keybinds.gather(
|
||||
"app",
|
||||
Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
|
||||
? appBindingCommands
|
||||
: appBindingCommands.filter(
|
||||
(c) => !c.startsWith("session.cycle_recent") && !c.startsWith("session.quick_switch"),
|
||||
),
|
||||
),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
|
||||
@@ -31,8 +31,6 @@ export function DialogSessionList() {
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
const deleteHint = useCommandShortcut("session.delete")
|
||||
const quickSwitch1 = useCommandShortcut("session.quick_switch.1")
|
||||
const quickSwitch9 = useCommandShortcut("session.quick_switch.9")
|
||||
|
||||
const [searchResults, { refetch }] = createResource(
|
||||
() => ({ query: search(), filter: sync.session.query() }),
|
||||
@@ -132,18 +130,10 @@ export function DialogSessionList() {
|
||||
|
||||
const [browseOrder] = createSignal<string[]>(orderByRecency(sync.data.session))
|
||||
|
||||
const quickSwitchHint = createMemo(() => {
|
||||
const first = quickSwitch1()
|
||||
const last = quickSwitch9()
|
||||
if (!first || !last) return undefined
|
||||
return quickSwitchRange(first, last)
|
||||
})
|
||||
const quickSwitchFooterHints = createMemo(() => {
|
||||
const hint = quickSwitchHint()
|
||||
return hint && local.session.slots().length > 0 ? [{ title: "switch", label: hint }] : []
|
||||
})
|
||||
const RECENT_LIMIT = 5
|
||||
|
||||
const options = createMemo(() => {
|
||||
const enabled = Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
|
||||
const today = new Date().toDateString()
|
||||
const sessionMap = new Map(
|
||||
sessions()
|
||||
@@ -154,9 +144,17 @@ export function DialogSessionList() {
|
||||
const searchResult = searchResults()
|
||||
const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder()
|
||||
|
||||
const pinned = local.session.pinned().filter((id) => sessionMap.has(id))
|
||||
const dismissed = enabled ? new Set(local.session.dismissedRecent()) : new Set<string>()
|
||||
const pinned = enabled ? local.session.pinned().filter((id) => sessionMap.has(id)) : []
|
||||
const pinnedSet = new Set(pinned)
|
||||
const slotByID = new Map<string, number>(local.session.slots().map((id, i) => [id, i + 1]))
|
||||
const slotByID = enabled
|
||||
? new Map<string, number>(local.session.slots().map((id, i) => [id, i + 1]))
|
||||
: new Map<string, number>()
|
||||
|
||||
const recent = enabled
|
||||
? displayOrder.filter((id) => !pinnedSet.has(id) && !dismissed.has(id)).slice(0, RECENT_LIMIT)
|
||||
: []
|
||||
const recentSet = new Set(recent)
|
||||
|
||||
function buildOption(id: string, category: string) {
|
||||
const x = sessionMap.get(id)
|
||||
@@ -200,7 +198,7 @@ export function DialogSessionList() {
|
||||
}
|
||||
|
||||
const remaining = displayOrder
|
||||
.filter((id) => !pinnedSet.has(id))
|
||||
.filter((id) => !pinnedSet.has(id) && !recentSet.has(id))
|
||||
.map((id) => {
|
||||
const x = sessionMap.get(id)
|
||||
if (!x) return undefined
|
||||
@@ -209,7 +207,11 @@ export function DialogSessionList() {
|
||||
})
|
||||
.filter((x) => x !== undefined)
|
||||
|
||||
return [...pinned.map((id) => buildOption(id, "Pinned")).filter((x) => x !== undefined), ...remaining]
|
||||
return [
|
||||
...pinned.map((id) => buildOption(id, "Pinned")).filter((x) => x !== undefined),
|
||||
...recent.map((id) => buildOption(id, "Recent")).filter((x) => x !== undefined),
|
||||
...remaining,
|
||||
]
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
@@ -234,13 +236,32 @@ export function DialogSessionList() {
|
||||
dialog.clear()
|
||||
}}
|
||||
actions={[
|
||||
{
|
||||
command: "session.pin.toggle",
|
||||
title: "pin/unpin",
|
||||
onTrigger: (option: { value: string }) => {
|
||||
local.session.togglePin(option.value)
|
||||
},
|
||||
},
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
|
||||
? [
|
||||
{
|
||||
command: "session.pin.toggle",
|
||||
title: "pin/unpin",
|
||||
onTrigger: (option: { value: string }) => {
|
||||
local.session.togglePin(option.value)
|
||||
},
|
||||
},
|
||||
{
|
||||
command: "session.toggle.recent",
|
||||
title: "toggle recent",
|
||||
onTrigger: (option: { value: string }) => {
|
||||
if (local.session.isPinned(option.value)) {
|
||||
toast.show({
|
||||
variant: "info",
|
||||
message: "Unpin the session first to toggle it in Recent",
|
||||
duration: 3000,
|
||||
})
|
||||
return
|
||||
}
|
||||
local.session.toggleRecent(option.value)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
command: "session.delete",
|
||||
title: "delete",
|
||||
@@ -297,13 +318,6 @@ export function DialogSessionList() {
|
||||
},
|
||||
},
|
||||
]}
|
||||
footerHints={quickSwitchFooterHints()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function quickSwitchRange(first: string, last: string) {
|
||||
const prefix = first.slice(0, -1)
|
||||
if (first.endsWith("1") && last === `${prefix}9`) return `${prefix}1-9`
|
||||
return `${first} through ${last}`
|
||||
}
|
||||
|
||||
@@ -87,6 +87,9 @@ export const Definitions = {
|
||||
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
|
||||
session_parent: keybind("up", "Go to parent session"),
|
||||
session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"),
|
||||
session_toggle_recent: keybind("ctrl+h", "Show or hide session in the Recent group"),
|
||||
session_cycle_recent: keybind("<leader>]", "Cycle to the previous recent session"),
|
||||
session_cycle_recent_reverse: keybind("<leader>[", "Cycle to the next recent session"),
|
||||
session_quick_switch_1: keybind("<leader>1", "Switch to session in quick slot 1"),
|
||||
session_quick_switch_2: keybind("<leader>2", "Switch to session in quick slot 2"),
|
||||
session_quick_switch_3: keybind("<leader>3", "Switch to session in quick slot 3"),
|
||||
@@ -270,6 +273,9 @@ export const CommandMap = {
|
||||
session_child_cycle_reverse: "session.child.previous",
|
||||
session_parent: "session.parent",
|
||||
session_pin_toggle: "session.pin.toggle",
|
||||
session_toggle_recent: "session.toggle.recent",
|
||||
session_cycle_recent: "session.cycle_recent",
|
||||
session_cycle_recent_reverse: "session.cycle_recent_reverse",
|
||||
session_quick_switch_1: "session.quick_switch.1",
|
||||
session_quick_switch_2: "session.quick_switch.2",
|
||||
session_quick_switch_3: "session.quick_switch.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { batch, createEffect, createMemo } from "solid-js"
|
||||
import { batch, createEffect, createMemo, on } from "solid-js"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
@@ -8,6 +8,7 @@ import { useEvent } from "@tui/context/event"
|
||||
import { uniqueBy } from "remeda"
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { iife } from "@/util/iife"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useArgs } from "./args"
|
||||
@@ -386,9 +387,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const [sessionStore, setSessionStore] = createStore<{
|
||||
ready: boolean
|
||||
pinned: string[]
|
||||
dismissedRecent: string[]
|
||||
recentOrder: string[]
|
||||
}>({
|
||||
ready: false,
|
||||
pinned: [],
|
||||
dismissedRecent: [],
|
||||
recentOrder: [],
|
||||
})
|
||||
|
||||
const filePath = path.join(Global.Path.state, "session.json")
|
||||
@@ -404,12 +409,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
state.pending = false
|
||||
void Filesystem.writeJson(filePath, {
|
||||
pinned: sessionStore.pinned,
|
||||
dismissedRecent: sessionStore.dismissedRecent,
|
||||
recentOrder: sessionStore.recentOrder,
|
||||
})
|
||||
}
|
||||
|
||||
Filesystem.readJson(filePath)
|
||||
.then((x: any) => {
|
||||
if (Array.isArray(x.pinned)) setSessionStore("pinned", x.pinned)
|
||||
if (Array.isArray(x.dismissedRecent)) setSessionStore("dismissedRecent", x.dismissedRecent)
|
||||
if (Array.isArray(x.recentOrder)) setSessionStore("recentOrder", x.recentOrder)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
@@ -419,10 +428,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
|
||||
const route = useRoute()
|
||||
const event = useEvent()
|
||||
let cycling = false
|
||||
|
||||
const slots = createMemo(() => {
|
||||
const existing = new Set(sync.data.session.filter((x) => x.parentID === undefined).map((x) => x.id))
|
||||
return sessionStore.pinned.filter((id) => existing.has(id)).slice(0, 9)
|
||||
const rootSessions = sync.data.session.filter((x) => x.parentID === undefined)
|
||||
const existing = new Set(rootSessions.map((x) => x.id))
|
||||
const dismissed = new Set(sessionStore.dismissedRecent)
|
||||
const pins = sessionStore.pinned.filter((id) => existing.has(id))
|
||||
const pinnedSet = new Set(pins)
|
||||
const recent = rootSessions
|
||||
.filter((x) => !pinnedSet.has(x.id) && !dismissed.has(x.id))
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
.map((x) => x.id)
|
||||
return [...pins, ...recent].slice(0, 9)
|
||||
})
|
||||
|
||||
function prune(sessionID: string) {
|
||||
@@ -433,6 +451,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
sessionStore.pinned.filter((x) => x !== sessionID),
|
||||
)
|
||||
}
|
||||
if (sessionStore.dismissedRecent.includes(sessionID)) {
|
||||
setSessionStore(
|
||||
"dismissedRecent",
|
||||
sessionStore.dismissedRecent.filter((x) => x !== sessionID),
|
||||
)
|
||||
}
|
||||
if (sessionStore.recentOrder.includes(sessionID)) {
|
||||
setSessionStore(
|
||||
"recentOrder",
|
||||
sessionStore.recentOrder.filter((x) => x !== sessionID),
|
||||
)
|
||||
}
|
||||
save()
|
||||
})
|
||||
}
|
||||
@@ -441,6 +471,25 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
prune(evt.properties.info.id)
|
||||
})
|
||||
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING) {
|
||||
createEffect(
|
||||
on(
|
||||
() => (sessionStore.ready && route.data.type === "session" ? route.data.sessionID : undefined),
|
||||
(sessionID) => {
|
||||
if (!sessionID) return
|
||||
if (cycling) {
|
||||
cycling = false
|
||||
return
|
||||
}
|
||||
const filtered = sessionStore.recentOrder.filter((x) => x !== sessionID)
|
||||
const next = [sessionID, ...filtered].slice(0, 20)
|
||||
setSessionStore("recentOrder", next)
|
||||
save()
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
get ready() {
|
||||
return sessionStore.ready
|
||||
@@ -448,10 +497,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
pinned() {
|
||||
return sessionStore.pinned
|
||||
},
|
||||
dismissedRecent() {
|
||||
return sessionStore.dismissedRecent
|
||||
},
|
||||
recentOrder() {
|
||||
return sessionStore.recentOrder
|
||||
},
|
||||
slots,
|
||||
isPinned(sessionID: string) {
|
||||
return sessionStore.pinned.includes(sessionID)
|
||||
},
|
||||
isDismissed(sessionID: string) {
|
||||
return sessionStore.dismissedRecent.includes(sessionID)
|
||||
},
|
||||
togglePin(sessionID: string) {
|
||||
batch(() => {
|
||||
const exists = sessionStore.pinned.includes(sessionID)
|
||||
@@ -462,12 +520,52 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
save()
|
||||
})
|
||||
},
|
||||
toggleRecent(sessionID: string) {
|
||||
batch(() => {
|
||||
const exists = sessionStore.dismissedRecent.includes(sessionID)
|
||||
const next = exists
|
||||
? sessionStore.dismissedRecent.filter((x) => x !== sessionID)
|
||||
: [sessionID, ...sessionStore.dismissedRecent]
|
||||
setSessionStore("dismissedRecent", next)
|
||||
save()
|
||||
})
|
||||
},
|
||||
quickSwitch(slot: number) {
|
||||
const target = slots()[slot - 1]
|
||||
if (!target) return
|
||||
if (route.data.type === "session" && route.data.sessionID === target) return
|
||||
route.navigate({ type: "session", sessionID: target })
|
||||
},
|
||||
cycleRecent(direction: 1 | -1) {
|
||||
if (route.data.type !== "session") {
|
||||
toast.show({
|
||||
variant: "info",
|
||||
message: "Open a session first to cycle between recent sessions",
|
||||
duration: 3000,
|
||||
})
|
||||
return
|
||||
}
|
||||
const current = route.data.sessionID
|
||||
const order = sessionStore.recentOrder.filter((id) =>
|
||||
sync.data.session.some((s) => s.id === id && s.parentID === undefined),
|
||||
)
|
||||
if (order.length < 2) {
|
||||
toast.show({
|
||||
variant: "info",
|
||||
message: "No other recent sessions to cycle to",
|
||||
duration: 3000,
|
||||
})
|
||||
return
|
||||
}
|
||||
const index = order.indexOf(current)
|
||||
if (index === -1) return
|
||||
const next = index + direction
|
||||
if (next < 0 || next >= order.length) return
|
||||
const target = order[next]
|
||||
if (!target || target === current) return
|
||||
cycling = true
|
||||
route.navigate({ type: "session", sessionID: target })
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { createMemo, type Setter } from "solid-js"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { useKV } from "./kv"
|
||||
|
||||
export type ThinkingMode = "show" | "minimal" | "hide"
|
||||
|
||||
const MODES: readonly ThinkingMode[] = ["show", "minimal", "hide"] as const
|
||||
|
||||
// OpenAI's Responses API surfaces reasoning summaries that start with a bolded
|
||||
// title line: "**Inspecting PR workflow**\n\n<body>". GitHub Copilot routes
|
||||
// through the same shape, and the opencode provider relays it too. Pull the
|
||||
// title out for a nicer label; return null for providers that don't follow
|
||||
// this convention so the caller can fall back to a generic "Thinking" string.
|
||||
export function reasoningTitle(text: string): string | null {
|
||||
const match = text.trimStart().match(/^\*\*([^*\n]+)\*\*/)
|
||||
return match ? match[1].trim() : null
|
||||
}
|
||||
|
||||
export function isThinkingMode(value: unknown): value is ThinkingMode {
|
||||
return typeof value === "string" && (MODES as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
// Cycle order matches the slash command: show → minimal → hide → show.
|
||||
export function nextThinkingMode(current: ThinkingMode): ThinkingMode {
|
||||
const idx = MODES.indexOf(current)
|
||||
return MODES[(idx + 1) % MODES.length] ?? "show"
|
||||
}
|
||||
|
||||
export function useThinkingMode() {
|
||||
const kv = useKV()
|
||||
// Capture pre-state before `kv.signal` seeds a default, so we can detect
|
||||
// first-time users with a legacy `thinking_visibility` boolean and migrate.
|
||||
// The KVProvider only renders children once kv.ready, so reads here are safe.
|
||||
const hadStored = kv.get("thinking_mode") !== undefined
|
||||
const legacy = kv.get("thinking_visibility")
|
||||
const [stored, setStored] = kv.signal<ThinkingMode>("thinking_mode", "minimal")
|
||||
|
||||
// The kv signal exposes its setter typed as `Setter<T>` which carries Solid's
|
||||
// overload set; passing an updater fn through a property access loses the
|
||||
// bivariance trick the existing `setX((prev) => ...)` callsites rely on.
|
||||
// Wrap it in a sane shape so consumers can just call `set(next)` or pass
|
||||
// an updater.
|
||||
const set = (next: ThinkingMode | ((prev: ThinkingMode) => ThinkingMode)) => {
|
||||
if (typeof next === "function") setStored(next as Setter<ThinkingMode>)
|
||||
else setStored(() => next)
|
||||
}
|
||||
|
||||
// Preserve previous experience for users who had explicitly toggled the
|
||||
// legacy `thinking_visibility` boolean. First-time users (no legacy key)
|
||||
// get the new "minimal" default.
|
||||
if (!hadStored) {
|
||||
if (legacy === true) set("show")
|
||||
else if (legacy === false) set("hide")
|
||||
}
|
||||
|
||||
const mode = createMemo<ThinkingMode>(() => {
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_MINIMAL_THINKING) return "minimal"
|
||||
const value = stored()
|
||||
return isThinkingMode(value) ? value : "minimal"
|
||||
})
|
||||
|
||||
return {
|
||||
mode,
|
||||
set,
|
||||
locked: () => Flag.OPENCODE_EXPERIMENTAL_MINIMAL_THINKING === true,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, For, type Accessor } from "solid-js"
|
||||
import { DEFAULT_THEMES, useTheme } from "@tui/context/theme"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { useCommandShortcut } from "../../keymap"
|
||||
|
||||
const themeCount = Object.keys(DEFAULT_THEMES).length
|
||||
@@ -28,6 +29,8 @@ type Shortcuts = {
|
||||
messagesToggleConceal: TipShortcut
|
||||
modelCycleRecent: TipShortcut
|
||||
modelList: TipShortcut
|
||||
sessionCycleRecent: TipShortcut
|
||||
sessionCycleRecentReverse: TipShortcut
|
||||
sessionExport: TipShortcut
|
||||
sessionInterrupt: TipShortcut
|
||||
sessionList: TipShortcut
|
||||
@@ -38,6 +41,7 @@ type Shortcuts = {
|
||||
sessionQuickSwitch9: TipShortcut
|
||||
sessionSidebarToggle: TipShortcut
|
||||
sessionTimeline: TipShortcut
|
||||
sessionToggleRecent: TipShortcut
|
||||
statusView: TipShortcut
|
||||
terminalSuspend: TipShortcut
|
||||
themeList: TipShortcut
|
||||
@@ -69,7 +73,6 @@ function parse(tip: string): TipPart[] {
|
||||
}
|
||||
|
||||
const NO_MODELS_TIP = "Run {highlight}/connect{/highlight} to add an AI provider and start coding"
|
||||
const NO_MODELS_PARTS = parse(NO_MODELS_TIP)
|
||||
|
||||
function shortcutText(value: string) {
|
||||
return `{highlight}${value}{/highlight}`
|
||||
@@ -118,6 +121,8 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
|
||||
messagesToggleConceal: configShortcut(props.api, "session.toggle.conceal"),
|
||||
modelCycleRecent: useCommandShortcut("model.cycle_recent"),
|
||||
modelList: useCommandShortcut("model.list"),
|
||||
sessionCycleRecent: useCommandShortcut("session.cycle_recent"),
|
||||
sessionCycleRecentReverse: useCommandShortcut("session.cycle_recent_reverse"),
|
||||
sessionExport: configShortcut(props.api, "session.export"),
|
||||
sessionInterrupt: configShortcut(props.api, "session.interrupt"),
|
||||
sessionList: useCommandShortcut("session.list"),
|
||||
@@ -128,6 +133,7 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
|
||||
sessionQuickSwitch9: useCommandShortcut("session.quick_switch.9"),
|
||||
sessionSidebarToggle: configShortcut(props.api, "session.sidebar.toggle"),
|
||||
sessionTimeline: configShortcut(props.api, "session.timeline"),
|
||||
sessionToggleRecent: configShortcut(props.api, "session.toggle.recent"),
|
||||
statusView: useCommandShortcut("opencode.status"),
|
||||
terminalSuspend: useCommandShortcut("terminal.suspend"),
|
||||
themeList: useCommandShortcut("theme.switch"),
|
||||
@@ -139,13 +145,8 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
|
||||
return value ? [value] : []
|
||||
})
|
||||
return tips[Math.floor(tipOffset * tips.length)] ?? NO_MODELS_TIP
|
||||
}, NO_MODELS_TIP)
|
||||
// Solid can expose a memo's initial value while a pure computation is pending.
|
||||
const parts = createMemo(() => {
|
||||
const value = tip()
|
||||
if (typeof value === "string") return parse(value)
|
||||
return NO_MODELS_PARTS
|
||||
}, NO_MODELS_PARTS)
|
||||
})
|
||||
const parts = createMemo(() => parse(tip()))
|
||||
|
||||
return (
|
||||
<box flexDirection="row" maxWidth="100%">
|
||||
@@ -175,12 +176,23 @@ const TIPS: Tip[] = [
|
||||
(shortcuts) => `Use ${commandText("/models", shortcuts.modelList())} to see and switch between available AI models`,
|
||||
(shortcuts) => `Use ${commandText("/themes", shortcuts.themeList())} to switch between ${themeCount} built-in themes`,
|
||||
(shortcuts) => `Use ${commandText("/new", shortcuts.sessionNew())} to start a fresh conversation session`,
|
||||
(shortcuts) => `Use ${commandText("/sessions", shortcuts.sessionList())} to list, pin, and continue sessions`,
|
||||
(shortcuts) => press(shortcuts.sessionPinToggle(), "in the session list to pin a session so it stays at the top"),
|
||||
(shortcuts) =>
|
||||
shortcuts.sessionQuickSwitch1() && shortcuts.sessionQuickSwitch9()
|
||||
? `Pinned sessions are assigned quick slots; use ${shortcutText(shortcuts.sessionQuickSwitch1())} through ${shortcutText(shortcuts.sessionQuickSwitch9())} to switch`
|
||||
: undefined,
|
||||
(shortcuts) => `Use ${commandText("/sessions", shortcuts.sessionList())} to list and continue previous conversations`,
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
|
||||
? ([
|
||||
(shortcuts) =>
|
||||
press(shortcuts.sessionPinToggle(), "in the session list to pin a session so it stays at the top"),
|
||||
(shortcuts) =>
|
||||
shortcuts.sessionQuickSwitch1() && shortcuts.sessionQuickSwitch9()
|
||||
? `Pinned and recent sessions are bound to ${shortcutText(shortcuts.sessionQuickSwitch1())} through ${shortcutText(shortcuts.sessionQuickSwitch9())} for one-press switching`
|
||||
: undefined,
|
||||
(shortcuts) =>
|
||||
shortcuts.sessionCycleRecent() && shortcuts.sessionCycleRecentReverse()
|
||||
? `Press ${shortcutText(shortcuts.sessionCycleRecent())} / ${shortcutText(shortcuts.sessionCycleRecentReverse())} to cycle through recently visited sessions`
|
||||
: undefined,
|
||||
(shortcuts) =>
|
||||
press(shortcuts.sessionToggleRecent(), "in the session list to show or hide a session in the Recent group"),
|
||||
] satisfies Tip[])
|
||||
: []),
|
||||
"Run {highlight}/compact{/highlight} to summarize long sessions near context limits",
|
||||
(shortcuts) => `Use ${commandText("/export", shortcuts.sessionExport())} to save the conversation as Markdown`,
|
||||
(shortcuts) => press(shortcuts.messagesCopy(), "to copy the assistant's last message to clipboard"),
|
||||
|
||||
@@ -5,7 +5,6 @@ import { SplitBorder } from "@tui/component/border"
|
||||
import { Spinner } from "@tui/component/spinner"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { useLocal } from "@tui/context/local"
|
||||
import { reasoningTitle, useThinkingMode } from "@tui/context/thinking"
|
||||
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
|
||||
import { useBindings } from "../../keymap"
|
||||
@@ -318,11 +317,7 @@ function AssistantMessage(props: {
|
||||
<AssistantText part={part as SessionMessageAssistantText} syntax={props.syntax} />
|
||||
</Match>
|
||||
<Match when={part.type === "reasoning"}>
|
||||
<AssistantReasoning
|
||||
part={part as SessionMessageAssistantReasoning}
|
||||
subtleSyntax={props.subtleSyntax}
|
||||
completedAt={() => props.message.time.completed}
|
||||
/>
|
||||
<AssistantReasoning part={part as SessionMessageAssistantReasoning} subtleSyntax={props.subtleSyntax} />
|
||||
</Match>
|
||||
<Match when={part.type === "tool"}>
|
||||
<AssistantTool part={part as SessionMessageAssistantTool} sessionID={props.sessionID} />
|
||||
@@ -383,64 +378,30 @@ function AssistantText(props: { part: SessionMessageAssistantText; syntax: Synta
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantReasoning(props: {
|
||||
part: SessionMessageAssistantReasoning
|
||||
subtleSyntax: SyntaxStyle
|
||||
completedAt: () => number | undefined
|
||||
}) {
|
||||
function AssistantReasoning(props: { part: SessionMessageAssistantReasoning; subtleSyntax: SyntaxStyle }) {
|
||||
const { theme } = useTheme()
|
||||
const thinking = useThinkingMode()
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const content = createMemo(() => props.part.text.replace("[REDACTED]", "").trim())
|
||||
const inMinimal = createMemo(() => thinking.mode() === "minimal")
|
||||
// v2 reasoning parts have no per-part `time.end` (see SessionMessageAssistantReasoning
|
||||
// in the v2 SDK); we settle on parent-message completion instead.
|
||||
const isDone = createMemo(() => props.completedAt() !== undefined)
|
||||
const title = createMemo(() => reasoningTitle(content()))
|
||||
|
||||
const toggle = () => {
|
||||
if (!inMinimal()) return
|
||||
setExpanded((prev) => !prev)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={content() && thinking.mode() !== "hide"}>
|
||||
<Switch>
|
||||
<Match when={!inMinimal() || expanded()}>
|
||||
<box
|
||||
paddingLeft={2}
|
||||
marginTop={1}
|
||||
flexDirection="column"
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.backgroundElement}
|
||||
flexShrink={0}
|
||||
onMouseUp={toggle}
|
||||
>
|
||||
<code
|
||||
filetype="markdown"
|
||||
drawUnstyledText={false}
|
||||
streaming={true}
|
||||
syntaxStyle={props.subtleSyntax}
|
||||
content={(inMinimal() ? "▼ " : "") + "_Thinking:_ " + content()}
|
||||
conceal={true}
|
||||
fg={theme.textMuted}
|
||||
/>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={isDone()}>
|
||||
<box paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
|
||||
<text fg={theme.textMuted} wrapMode="none">
|
||||
{title() ? "▶ Thought: " + title() : "▶ Thought"}
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<box paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
|
||||
<Spinner color={theme.textMuted}>{title() ? "Thinking: " + title() : "Thinking"}</Spinner>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show when={content()}>
|
||||
<box
|
||||
paddingLeft={2}
|
||||
marginTop={1}
|
||||
flexDirection="column"
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.backgroundElement}
|
||||
flexShrink={0}
|
||||
>
|
||||
<code
|
||||
filetype="markdown"
|
||||
drawUnstyledText={false}
|
||||
streaming={true}
|
||||
syntaxStyle={props.subtleSyntax}
|
||||
content={"_Thinking:_ " + content()}
|
||||
conceal={true}
|
||||
fg={theme.textMuted}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1067,16 +1067,14 @@ async function load(input: { api: Api; config: TuiConfig.Resolved; dispose?: ()
|
||||
}
|
||||
runtime = next
|
||||
try {
|
||||
const flags = await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return yield* RuntimeFlags.Service
|
||||
}).pipe(Effect.provide(RuntimeFlags.defaultLayer)),
|
||||
)
|
||||
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
|
||||
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
|
||||
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
|
||||
}
|
||||
|
||||
const flags = await Effect.runPromise(
|
||||
RuntimeFlags.Service.use((flags) => Effect.succeed(flags)).pipe(Effect.provide(RuntimeFlags.defaultLayer)),
|
||||
)
|
||||
for (const item of internalTuiPlugins(flags)) {
|
||||
log.info("loading internal tui plugin", { id: item.id })
|
||||
const entry = loadInternalPlugin(item)
|
||||
|
||||
@@ -82,7 +82,6 @@ import * as Model from "../../util/model"
|
||||
import { formatTranscript } from "../../util/transcript"
|
||||
import { UI } from "@/cli/ui.ts"
|
||||
import { useTuiConfig } from "../../context/tui-config"
|
||||
import { nextThinkingMode, reasoningTitle, useThinkingMode, type ThinkingMode } from "../../context/thinking"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
|
||||
import { DialogRetryAction } from "../../component/dialog-retry-action"
|
||||
@@ -158,7 +157,6 @@ const context = createContext<{
|
||||
width: number
|
||||
sessionID: string
|
||||
conceal: () => boolean
|
||||
thinkingMode: () => ThinkingMode
|
||||
showThinking: () => boolean
|
||||
showTimestamps: () => boolean
|
||||
showDetails: () => boolean
|
||||
@@ -216,9 +214,7 @@ export function Session() {
|
||||
const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto")
|
||||
const [sidebarOpen, setSidebarOpen] = createSignal(false)
|
||||
const [conceal, setConceal] = createSignal(true)
|
||||
const thinking = useThinkingMode()
|
||||
const thinkingMode = thinking.mode
|
||||
const showThinking = createMemo(() => thinkingMode() !== "hide")
|
||||
const [showThinking, setShowThinking] = kv.signal("thinking_visibility", true)
|
||||
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
|
||||
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
|
||||
const [showAssistantMetadata, _setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
|
||||
@@ -687,12 +683,7 @@ export function Session() {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: (() => {
|
||||
const next = nextThinkingMode(thinkingMode())
|
||||
if (next === "minimal") return "Switch thinking to minimal"
|
||||
if (next === "hide") return "Hide thinking"
|
||||
return "Show thinking"
|
||||
})(),
|
||||
title: showThinking() ? "Hide thinking" : "Show thinking",
|
||||
value: "session.toggle.thinking",
|
||||
category: "Session",
|
||||
slash: {
|
||||
@@ -700,17 +691,7 @@ export function Session() {
|
||||
aliases: ["toggle-thinking"],
|
||||
},
|
||||
run: () => {
|
||||
// Env override forces minimal for the process. Updating KV here would
|
||||
// silently diverge from what's rendered; tell the user instead.
|
||||
if (thinking.locked()) {
|
||||
toast.show({
|
||||
message: "Thinking mode is locked to minimal by OPENCODE_EXPERIMENTAL_MINIMAL_THINKING",
|
||||
variant: "info",
|
||||
})
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
thinking.set(nextThinkingMode(thinkingMode()))
|
||||
setShowThinking((prev) => !prev)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -1105,7 +1086,6 @@ export function Session() {
|
||||
},
|
||||
sessionID: route.sessionID,
|
||||
conceal,
|
||||
thinkingMode,
|
||||
showThinking,
|
||||
showTimestamps,
|
||||
showDetails,
|
||||
@@ -1512,77 +1492,32 @@ const PART_MAPPING = {
|
||||
function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
|
||||
const { theme, subtleSyntax } = useTheme()
|
||||
const ctx = use()
|
||||
// Collapsed by default in minimal mode: a single line throughout, so the
|
||||
// layout never shifts. Click to open the full markdown block, click to close.
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
|
||||
const content = createMemo(() => {
|
||||
// OpenRouter encrypts some reasoning blocks; drop the placeholder.
|
||||
// Filter out redacted reasoning chunks from OpenRouter
|
||||
// OpenRouter sends encrypted reasoning data that appears as [REDACTED]
|
||||
return props.part.text.replace("[REDACTED]", "").trim()
|
||||
})
|
||||
// Reasoning is finalized when the server sets `time.end` (see processor.ts).
|
||||
// Flips independently of the parent message completing.
|
||||
const isDone = createMemo(() => props.part.time.end !== undefined)
|
||||
const inMinimal = createMemo(() => ctx.thinkingMode() === "minimal")
|
||||
const duration = createMemo(() => {
|
||||
const end = props.part.time.end
|
||||
return end === undefined ? 0 : Math.max(0, end - props.part.time.start)
|
||||
})
|
||||
// OpenAI / Copilot / opencode-via-OpenAI emit `**Title**\n\n<body>` summary
|
||||
// blocks. Surface the title both while streaming and after settling so the
|
||||
// collapsed line carries real signal, not just a duration.
|
||||
const title = createMemo(() => reasoningTitle(content()))
|
||||
|
||||
const toggle = () => {
|
||||
if (!inMinimal()) return
|
||||
setExpanded((prev) => !prev)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={content() && ctx.thinkingMode() !== "hide"}>
|
||||
<Switch>
|
||||
<Match when={!inMinimal() || expanded()}>
|
||||
{/* Full markdown block: `show` mode, or `minimal` after the user opens it. */}
|
||||
<box
|
||||
id={"text-" + props.part.id}
|
||||
paddingLeft={2}
|
||||
marginTop={1}
|
||||
flexDirection="column"
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.backgroundElement}
|
||||
onMouseUp={toggle}
|
||||
>
|
||||
<code
|
||||
filetype="markdown"
|
||||
drawUnstyledText={false}
|
||||
streaming={true}
|
||||
syntaxStyle={subtleSyntax()}
|
||||
content={(inMinimal() ? "▼ " : "") + "_Thinking:_ " + content()}
|
||||
conceal={ctx.conceal()}
|
||||
fg={theme.textMuted}
|
||||
/>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={isDone()}>
|
||||
{/* Settled: ▶ at the start as the click-to-expand cue. */}
|
||||
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
|
||||
<text fg={theme.textMuted} wrapMode="none">
|
||||
{"▶ " +
|
||||
(title()
|
||||
? "Thought: " + title() + " · " + Locale.duration(duration())
|
||||
: "Thought for " + Locale.duration(duration()))}
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
{/* Streaming: leading animated spinner, no disclosure arrow yet — it
|
||||
snaps in once reasoning settles, signalling "done, click to expand". */}
|
||||
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
|
||||
<Spinner color={theme.textMuted}>{title() ? "Thinking: " + title() : "Thinking"}</Spinner>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show when={content() && ctx.showThinking()}>
|
||||
<box
|
||||
id={"text-" + props.part.id}
|
||||
paddingLeft={2}
|
||||
marginTop={1}
|
||||
flexDirection="column"
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.backgroundElement}
|
||||
>
|
||||
<code
|
||||
filetype="markdown"
|
||||
drawUnstyledText={false}
|
||||
streaming={true}
|
||||
syntaxStyle={subtleSyntax()}
|
||||
content={"_Thinking:_ " + content()}
|
||||
conceal={ctx.conceal()}
|
||||
fg={theme.textMuted}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -1593,15 +1528,14 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
|
||||
return (
|
||||
<Show when={props.part.text.trim()}>
|
||||
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
|
||||
<markdown
|
||||
syntaxStyle={syntax()}
|
||||
<code
|
||||
filetype="markdown"
|
||||
drawUnstyledText={false}
|
||||
streaming={true}
|
||||
internalBlockMode="top-level"
|
||||
syntaxStyle={syntax()}
|
||||
content={props.part.text.trim()}
|
||||
tableOptions={{ style: "grid" }}
|
||||
conceal={ctx.conceal()}
|
||||
fg={theme.markdownText}
|
||||
bg={theme.background}
|
||||
fg={theme.text}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
@@ -2079,9 +2013,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
||||
|
||||
const content = createMemo(() => {
|
||||
if (!props.input.description) return ""
|
||||
const description =
|
||||
props.metadata.background === true ? `${props.input.description} (background)` : props.input.description
|
||||
let content = [`${Locale.titlecase(props.input.subagent_type ?? "General")} Task — ${description}`]
|
||||
let content = [`${Locale.titlecase(props.input.subagent_type ?? "General")} Task — ${props.input.description}`]
|
||||
|
||||
if (isRunning() && tools().length > 0) {
|
||||
// content[0] += ` · ${tools().length} toolcalls`
|
||||
@@ -2093,11 +2025,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
||||
}
|
||||
|
||||
if (props.part.state.status === "completed") {
|
||||
content.push(
|
||||
props.metadata.background === true
|
||||
? `└ ${tools().length} toolcalls`
|
||||
: `└ ${tools().length} toolcalls · ${Locale.duration(duration())}`,
|
||||
)
|
||||
content.push(`└ ${tools().length} toolcalls · ${Locale.duration(duration())}`)
|
||||
}
|
||||
|
||||
return content.join("\n")
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { selectedForeground, tint, useTheme } from "../../context/theme"
|
||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
@@ -13,7 +12,6 @@ import { useBindings } from "../../keymap"
|
||||
export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
const sdk = useSDK()
|
||||
const { theme } = useTheme()
|
||||
const renderer = useRenderer()
|
||||
const tuiConfig = useTuiConfig()
|
||||
|
||||
const questions = createMemo(() => props.request.questions)
|
||||
@@ -304,10 +302,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
}
|
||||
onMouseOver={() => setTabHover(index())}
|
||||
onMouseOut={() => setTabHover(null)}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
selectTab(index())
|
||||
}}
|
||||
onMouseUp={() => selectTab(index())}
|
||||
>
|
||||
<text
|
||||
fg={
|
||||
@@ -332,10 +327,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
}
|
||||
onMouseOver={() => setTabHover("confirm")}
|
||||
onMouseOut={() => setTabHover(null)}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
selectTab(questions().length)
|
||||
}}
|
||||
onMouseUp={() => selectTab(questions().length)}
|
||||
>
|
||||
<text fg={confirm() ? selectedForeground(theme, theme.accent) : theme.textMuted}>Confirm</text>
|
||||
</box>
|
||||
@@ -359,10 +351,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
<box
|
||||
onMouseOver={() => moveTo(i())}
|
||||
onMouseDown={() => moveTo(i())}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
selectOption()
|
||||
}}
|
||||
onMouseUp={() => selectOption()}
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box backgroundColor={active() ? theme.backgroundElement : undefined} paddingRight={1}>
|
||||
@@ -391,10 +380,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
<box
|
||||
onMouseOver={() => moveTo(options().length)}
|
||||
onMouseDown={() => moveTo(options().length)}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
selectOption()
|
||||
}}
|
||||
onMouseUp={() => selectOption()}
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box backgroundColor={other() ? theme.backgroundElement : undefined} paddingRight={1}>
|
||||
|
||||
@@ -38,11 +38,6 @@ export interface DialogSelectProps<T> {
|
||||
disabled?: boolean
|
||||
onTrigger: (option: DialogSelectOption<T>) => void
|
||||
}[]
|
||||
footerHints?: {
|
||||
title: string
|
||||
label: string
|
||||
side?: "left" | "right"
|
||||
}[]
|
||||
bindings?: readonly Binding<Renderable, KeyEvent>[]
|
||||
current?: T
|
||||
}
|
||||
@@ -339,12 +334,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
}
|
||||
props.ref?.(ref)
|
||||
|
||||
const visibleActions = createMemo(() => [
|
||||
...actions()
|
||||
const visibleActions = createMemo(() =>
|
||||
actions()
|
||||
.map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" }))
|
||||
.filter((item) => !item.disabled && item.label),
|
||||
...(props.footerHints ?? []),
|
||||
])
|
||||
)
|
||||
const left = createMemo(() => visibleActions().filter((item) => item.side !== "right"))
|
||||
const right = createMemo(() => visibleActions().filter((item) => item.side === "right"))
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Installation } from "@/installation"
|
||||
import { Server } from "@/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { WithInstance } from "@/project/with-instance"
|
||||
import { Rpc } from "@/util/rpc"
|
||||
import { upgrade } from "@/cli/upgrade"
|
||||
import { Config } from "@/config/config"
|
||||
@@ -76,8 +77,12 @@ export const rpc = {
|
||||
return { url: server.url.toString() }
|
||||
},
|
||||
async checkUpgrade(input: { directory: string }) {
|
||||
await InstanceRuntime.load({ directory: input.directory })
|
||||
await upgrade().catch(() => {})
|
||||
await WithInstance.provide({
|
||||
directory: input.directory,
|
||||
fn: async () => {
|
||||
await upgrade().catch(() => {})
|
||||
},
|
||||
})
|
||||
},
|
||||
async reload() {
|
||||
await AppRuntime.runPromise(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Effect, Schema } from "effect"
|
||||
import { AppRuntime, type AppServices } from "@/effect/app-runtime"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { cmd, type WithDoubleDash } from "./cmd/cmd"
|
||||
|
||||
/**
|
||||
@@ -82,11 +83,19 @@ export const effectCmd = <Args, A>(opts: EffectCmdOpts<Args, A>) =>
|
||||
return
|
||||
}
|
||||
const directory = opts.directory?.(args) ?? process.cwd()
|
||||
// Two-phase: load ctx, then run body inside Instance.current ALS.
|
||||
// Effect's InstanceRef is provided via fiber context, but that context is
|
||||
// lost across `await` inside `Effect.promise(async () => ...)` callbacks
|
||||
// — when handlers re-enter Effect via `AppRuntime.runPromise(svc.method())`
|
||||
// there, attach() falls back to Instance.current ALS, which Node preserves
|
||||
// across awaits. Matches the pre-effectCmd `bootstrap()` behavior.
|
||||
const { store, ctx } = await AppRuntime.runPromise(
|
||||
InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))),
|
||||
)
|
||||
try {
|
||||
await AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx)))
|
||||
await Instance.restore(ctx, () =>
|
||||
AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx))),
|
||||
)
|
||||
} finally {
|
||||
await AppRuntime.runPromise(store.dispose(ctx))
|
||||
}
|
||||
|
||||
@@ -72,9 +72,8 @@ export function FormatError(input: unknown) {
|
||||
}
|
||||
|
||||
// ProviderInitError: { providerID: string }
|
||||
const providerInit = configData(input, "ProviderInitError")
|
||||
if (providerInit) {
|
||||
return `Failed to initialize provider "${stringField(providerInit, "providerID")}". Check credentials and configuration.`
|
||||
if (NamedError.hasName(input, "ProviderInitError")) {
|
||||
return `Failed to initialize provider "${(input as ErrorLike).data?.providerID}". Check credentials and configuration.`
|
||||
}
|
||||
|
||||
// ConfigJsonError: { path: string, message?: string }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
import type { InstanceContext } from "@/project/instance"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { Config } from "@/config/config"
|
||||
|
||||
@@ -14,7 +14,7 @@ export const Image = Schema.Struct({
|
||||
description: "Maximum image height before resizing or rejecting the attachment (default: 2000)",
|
||||
}),
|
||||
max_base64_bytes: Schema.optional(PositiveInt).annotate({
|
||||
description: "Maximum base64 payload bytes for an image attachment (default: 5242880)",
|
||||
description: "Maximum base64 payload bytes for an image attachment (default: 4718592)",
|
||||
}),
|
||||
}).annotate({ identifier: "ImageAttachmentConfig" })
|
||||
export type Image = Schema.Schema.Type<typeof Image>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user