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": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.15.0",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.15.0",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.15.0",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.15.0",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "3.0.64",
|
"@ai-sdk/anthropic": "3.0.64",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@ai-sdk/openai": "3.0.48",
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.15.0",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@opencode-ai/core",
|
"name": "@opencode-ai/core",
|
||||||
"version": "1.15.0",
|
"version": "1.14.49",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -253,7 +253,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.15.0",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -307,7 +307,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.15.0",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -337,7 +337,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.15.0",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -353,7 +353,7 @@
|
|||||||
},
|
},
|
||||||
"packages/http-recorder": {
|
"packages/http-recorder": {
|
||||||
"name": "@opencode-ai/http-recorder",
|
"name": "@opencode-ai/http-recorder",
|
||||||
"version": "1.15.0",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -366,7 +366,7 @@
|
|||||||
},
|
},
|
||||||
"packages/llm": {
|
"packages/llm": {
|
||||||
"name": "@opencode-ai/llm",
|
"name": "@opencode-ai/llm",
|
||||||
"version": "1.15.0",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@smithy/eventstream-codec": "4.2.14",
|
"@smithy/eventstream-codec": "4.2.14",
|
||||||
"@smithy/util-utf8": "4.2.2",
|
"@smithy/util-utf8": "4.2.2",
|
||||||
@@ -384,7 +384,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.15.0",
|
"version": "1.14.49",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -520,7 +520,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.15.0",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -536,9 +536,9 @@
|
|||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opentui/core": ">=0.2.10",
|
"@opentui/core": ">=0.2.9",
|
||||||
"@opentui/keymap": ">=0.2.10",
|
"@opentui/keymap": ">=0.2.9",
|
||||||
"@opentui/solid": ">=0.2.10",
|
"@opentui/solid": ">=0.2.9",
|
||||||
},
|
},
|
||||||
"optionalPeers": [
|
"optionalPeers": [
|
||||||
"@opentui/core",
|
"@opentui/core",
|
||||||
@@ -558,7 +558,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.15.0",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -573,7 +573,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.15.0",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -608,7 +608,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.15.0",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -657,7 +657,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.15.0",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -721,9 +721,9 @@
|
|||||||
"@npmcli/arborist": "9.4.0",
|
"@npmcli/arborist": "9.4.0",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||||
"@opentui/core": "0.2.10",
|
"@opentui/core": "0.2.9",
|
||||||
"@opentui/keymap": "0.2.10",
|
"@opentui/keymap": "0.2.9",
|
||||||
"@opentui/solid": "0.2.10",
|
"@opentui/solid": "0.2.9",
|
||||||
"@pierre/diffs": "1.1.0-beta.18",
|
"@pierre/diffs": "1.1.0-beta.18",
|
||||||
"@playwright/test": "1.59.1",
|
"@playwright/test": "1.59.1",
|
||||||
"@sentry/solid": "10.36.0",
|
"@sentry/solid": "10.36.0",
|
||||||
@@ -1590,23 +1590,23 @@
|
|||||||
|
|
||||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
|
"@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=="],
|
"@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
|
}).json
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerHttpErrorsQuery = () => {
|
const providerHttpErrorsQuery = (product: "go" | "zen") => {
|
||||||
const filters = [
|
const filters = [
|
||||||
{ column: "provider", op: "exists" },
|
{ column: "provider", op: "exists" },
|
||||||
{ column: "user_agent", op: "contains", value: "opencode" },
|
{ column: "user_agent", op: "contains", value: "opencode" },
|
||||||
|
{ column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" },
|
||||||
]
|
]
|
||||||
const successHttpStatus = calculatedField({
|
const successHttpStatus = calculatedField({
|
||||||
name: "is_success_http_status",
|
name: "is_success_http_status",
|
||||||
@@ -100,15 +101,11 @@ const providerHttpErrorsQuery = () => {
|
|||||||
name: "FAILED",
|
name: "FAILED",
|
||||||
column: failedProviderHttpStatus.name,
|
column: failedProviderHttpStatus.name,
|
||||||
filterCombination: "AND",
|
filterCombination: "AND",
|
||||||
filters: [
|
filters: [...filters, { column: "event_type", op: "=", value: "llm.error" }],
|
||||||
...filters,
|
|
||||||
{ column: "event_type", op: "=", value: "llm.error" },
|
|
||||||
{ column: "llm.error.code", op: "!=", value: "404" },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
formulas: [
|
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,
|
timeRange: 900,
|
||||||
}).json
|
}).json
|
||||||
@@ -218,10 +215,29 @@ new honeycomb.Trigger("LowModelTpsZen", {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
new honeycomb.Trigger("IncreasedProviderHttpErrors", {
|
new honeycomb.Trigger("IncreasedProviderHttpErrorsGo", {
|
||||||
name: "Increased Provider HTTP Errors",
|
name: "Increased Provider HTTP Errors [Go]",
|
||||||
description,
|
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",
|
alertType: "on_change",
|
||||||
frequency: 300,
|
frequency: 300,
|
||||||
thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }],
|
thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }],
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-Hw7sVV9rTm6qBMtdwfLIV2QvxvLQY5qrywXzuyYbhcs=",
|
"x86_64-linux": "sha256-QIj9PhOXR/GngV/dPjCF7n5rKro2fcTNGzJ47a41Z2Q=",
|
||||||
"aarch64-linux": "sha256-++oXnY7YqrYt0Qv7ZISmoHliARM9qEP8FacqLxGZH1c=",
|
"aarch64-linux": "sha256-fQl7BjjTYtRKT3HRVhubaIVww/puUFSTzVV5bTy8II8=",
|
||||||
"aarch64-darwin": "sha256-kZVa0R1YbuvtTzpETqK6ddj4ISje5jBFHBdlynkhW7Q=",
|
"aarch64-darwin": "sha256-81IAmdjiYZz8IgMJt0+VxzdOS80gTHc5SendwEW/vD4=",
|
||||||
"x86_64-darwin": "sha256-94eagNDa8GGJxF8BsMX2BF5Pa+QTl48lXL1+6HgEn0I="
|
"x86_64-darwin": "sha256-5OMX4VVBMfEmkYvzd09oksAt5hKkxDs84miO804LBI8="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -35,9 +35,9 @@
|
|||||||
"@types/cross-spawn": "6.0.6",
|
"@types/cross-spawn": "6.0.6",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"@opentui/core": "0.2.10",
|
"@opentui/core": "0.2.9",
|
||||||
"@opentui/keymap": "0.2.10",
|
"@opentui/keymap": "0.2.9",
|
||||||
"@opentui/solid": "0.2.10",
|
"@opentui/solid": "0.2.9",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@types/luxon": "3.7.1",
|
"@types/luxon": "3.7.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.15.0",
|
"version": "1.14.49",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ const statusLabels = {
|
|||||||
connected: "mcp.status.connected",
|
connected: "mcp.status.connected",
|
||||||
failed: "mcp.status.failed",
|
failed: "mcp.status.failed",
|
||||||
needs_auth: "mcp.status.needs_auth",
|
needs_auth: "mcp.status.needs_auth",
|
||||||
needs_client_registration: "mcp.status.needs_client_registration",
|
|
||||||
disabled: "mcp.status.disabled",
|
disabled: "mcp.status.disabled",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
@@ -32,16 +31,8 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
|
|
||||||
const toggle = useMutation(() => ({
|
const toggle = useMutation(() => ({
|
||||||
mutationFn: async (name: string) => {
|
mutationFn: async (name: string) => {
|
||||||
const status = sync.data.mcp[name]
|
if (sync.data.mcp[name]?.status === "connected") await sdk.client.mcp.disconnect({ name })
|
||||||
if (status?.status === "connected") {
|
else await sdk.client.mcp.connect({ name })
|
||||||
await sdk.client.mcp.disconnect({ name })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (status?.status === "needs_auth") {
|
|
||||||
await sdk.client.mcp.auth.authenticate({ name })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await sdk.client.mcp.connect({ name })
|
|
||||||
},
|
},
|
||||||
onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))),
|
onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))),
|
||||||
}))
|
}))
|
||||||
@@ -76,7 +67,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
}
|
}
|
||||||
const error = () => {
|
const error = () => {
|
||||||
const s = mcpStatus()
|
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"
|
const enabled = () => status() === "connected"
|
||||||
return (
|
return (
|
||||||
@@ -87,6 +78,9 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
<Show when={statusLabel()}>
|
<Show when={statusLabel()}>
|
||||||
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
|
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
|
||||||
</Show>
|
</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>
|
</div>
|
||||||
<Show when={error()}>
|
<Show when={error()}>
|
||||||
<span class="text-11-regular text-text-weaker truncate">{error()}</span>
|
<span class="text-11-regular text-text-weaker truncate">{error()}</span>
|
||||||
|
|||||||
@@ -145,15 +145,7 @@ const useMcpToggleMutation = () => {
|
|||||||
return useMutation(() => ({
|
return useMutation(() => ({
|
||||||
mutationFn: async (name: string) => {
|
mutationFn: async (name: string) => {
|
||||||
const status = sync.data.mcp[name]
|
const status = sync.data.mcp[name]
|
||||||
if (status?.status === "connected") {
|
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
|
||||||
await sdk.client.mcp.disconnect({ name })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (status?.status === "needs_auth") {
|
|
||||||
await sdk.client.mcp.auth.authenticate({ name })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await sdk.client.mcp.connect({ name })
|
|
||||||
},
|
},
|
||||||
onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))),
|
onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))),
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
@@ -324,7 +316,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="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={() => {
|
onClick={() => {
|
||||||
if (toggleMcp.isPending) return
|
if (toggleMcp.isPending) return
|
||||||
toggleMcp.mutate(name)
|
toggleMcp.mutate(name)
|
||||||
@@ -341,16 +333,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
status() === "needs_auth" || status() === "needs_client_registration",
|
status() === "needs_auth" || status() === "needs_client_registration",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span class="flex flex-col min-w-0 flex-1">
|
<span class="text-14-regular text-text-base truncate flex-1">{name}</span>
|
||||||
<span class="flex items-center gap-2 min-w-0">
|
|
||||||
<span class="text-14-regular text-text-base truncate">{name}</span>
|
|
||||||
</span>
|
|
||||||
<Show when={status() === "needs_auth"}>
|
|
||||||
<span class="text-11-regular text-text-weaker truncate">
|
|
||||||
{language.t("mcp.auth.clickToAuthenticate")}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
<div onClick={(event) => event.stopPropagation()}>
|
<div onClick={(event) => event.stopPropagation()}>
|
||||||
<Switch
|
<Switch
|
||||||
checked={enabled()}
|
checked={enabled()}
|
||||||
|
|||||||
@@ -14,14 +14,12 @@ export function StatusPopover() {
|
|||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const [shown, setShown] = createSignal(false)
|
const [shown, setShown] = createSignal(false)
|
||||||
const ready = createMemo(() => server.healthy() === false || sync.data.mcp_ready)
|
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 mcp = Object.values(sync.data.mcp ?? {})
|
||||||
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
|
const issue = mcp.some((item) => item.status !== "connected" && item.status !== "disabled")
|
||||||
const warn = mcp.some((item) => item.status === "needs_auth")
|
return serverHealthy && !issue
|
||||||
if (failed) return "critical" as const
|
|
||||||
if (warn) return "warning" as const
|
|
||||||
})
|
})
|
||||||
const healthy = createMemo(() => server.healthy() === true && !mcpIssue())
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
@@ -43,9 +41,7 @@ export function StatusPopover() {
|
|||||||
classList={{
|
classList={{
|
||||||
"absolute -top-px -right-px size-1.5 rounded-full": true,
|
"absolute -top-px -right-px size-1.5 rounded-full": true,
|
||||||
"bg-icon-success-base": ready() && healthy(),
|
"bg-icon-success-base": ready() && healthy(),
|
||||||
"bg-icon-warning-base": ready() && server.healthy() === true && mcpIssue() === "warning",
|
"bg-icon-critical-base": server.healthy() === false || (ready() && !healthy()),
|
||||||
"bg-icon-critical-base":
|
|
||||||
server.healthy() === false || (ready() && server.healthy() === true && mcpIssue() === "critical"),
|
|
||||||
"bg-border-weak-base": server.healthy() === undefined || !ready(),
|
"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 info = (event.properties as { info: Session }).info
|
||||||
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
||||||
if (info.time.archived) {
|
if (info.time.archived) {
|
||||||
if (input.store.session[result.index]!.time.archived === info.time.archived) break
|
|
||||||
if (result.found) {
|
if (result.found) {
|
||||||
input.setStore(
|
input.setStore(
|
||||||
"session",
|
"session",
|
||||||
|
|||||||
@@ -276,7 +276,6 @@ export const dict = {
|
|||||||
"mcp.status.connected": "متصل",
|
"mcp.status.connected": "متصل",
|
||||||
"mcp.status.failed": "فشل",
|
"mcp.status.failed": "فشل",
|
||||||
"mcp.status.needs_auth": "يحتاج إلى مصادقة",
|
"mcp.status.needs_auth": "يحتاج إلى مصادقة",
|
||||||
"mcp.auth.clickToAuthenticate": "انقر للمصادقة",
|
|
||||||
"mcp.status.disabled": "معطل",
|
"mcp.status.disabled": "معطل",
|
||||||
"dialog.fork.empty": "لا توجد رسائل للتفرع منها",
|
"dialog.fork.empty": "لا توجد رسائل للتفرع منها",
|
||||||
"dialog.directory.search.placeholder": "البحث في المجلدات",
|
"dialog.directory.search.placeholder": "البحث في المجلدات",
|
||||||
|
|||||||
@@ -276,7 +276,6 @@ export const dict = {
|
|||||||
"mcp.status.connected": "conectado",
|
"mcp.status.connected": "conectado",
|
||||||
"mcp.status.failed": "falhou",
|
"mcp.status.failed": "falhou",
|
||||||
"mcp.status.needs_auth": "precisa de autenticação",
|
"mcp.status.needs_auth": "precisa de autenticação",
|
||||||
"mcp.auth.clickToAuthenticate": "Clique para autenticar",
|
|
||||||
"mcp.status.disabled": "desabilitado",
|
"mcp.status.disabled": "desabilitado",
|
||||||
"dialog.fork.empty": "Nenhuma mensagem para bifurcar",
|
"dialog.fork.empty": "Nenhuma mensagem para bifurcar",
|
||||||
"dialog.directory.search.placeholder": "Buscar pastas",
|
"dialog.directory.search.placeholder": "Buscar pastas",
|
||||||
|
|||||||
@@ -300,7 +300,6 @@ export const dict = {
|
|||||||
"mcp.status.connected": "povezano",
|
"mcp.status.connected": "povezano",
|
||||||
"mcp.status.failed": "neuspjelo",
|
"mcp.status.failed": "neuspjelo",
|
||||||
"mcp.status.needs_auth": "potrebna autentifikacija",
|
"mcp.status.needs_auth": "potrebna autentifikacija",
|
||||||
"mcp.auth.clickToAuthenticate": "Kliknite za autentifikaciju",
|
|
||||||
"mcp.status.disabled": "onemogućeno",
|
"mcp.status.disabled": "onemogućeno",
|
||||||
|
|
||||||
"dialog.fork.empty": "Nema poruka za fork",
|
"dialog.fork.empty": "Nema poruka za fork",
|
||||||
|
|||||||
@@ -298,7 +298,6 @@ export const dict = {
|
|||||||
"mcp.status.connected": "forbundet",
|
"mcp.status.connected": "forbundet",
|
||||||
"mcp.status.failed": "mislykkedes",
|
"mcp.status.failed": "mislykkedes",
|
||||||
"mcp.status.needs_auth": "kræver godkendelse",
|
"mcp.status.needs_auth": "kræver godkendelse",
|
||||||
"mcp.auth.clickToAuthenticate": "Klik for at godkende",
|
|
||||||
"mcp.status.disabled": "deaktiveret",
|
"mcp.status.disabled": "deaktiveret",
|
||||||
|
|
||||||
"dialog.fork.empty": "Ingen beskeder at forgrene fra",
|
"dialog.fork.empty": "Ingen beskeder at forgrene fra",
|
||||||
|
|||||||
@@ -282,7 +282,6 @@ export const dict = {
|
|||||||
"mcp.status.connected": "verbunden",
|
"mcp.status.connected": "verbunden",
|
||||||
"mcp.status.failed": "fehlgeschlagen",
|
"mcp.status.failed": "fehlgeschlagen",
|
||||||
"mcp.status.needs_auth": "benötigt Authentifizierung",
|
"mcp.status.needs_auth": "benötigt Authentifizierung",
|
||||||
"mcp.auth.clickToAuthenticate": "Zum Authentifizieren klicken",
|
|
||||||
"mcp.status.disabled": "deaktiviert",
|
"mcp.status.disabled": "deaktiviert",
|
||||||
"dialog.fork.empty": "Keine Nachrichten zum Abzweigen vorhanden",
|
"dialog.fork.empty": "Keine Nachrichten zum Abzweigen vorhanden",
|
||||||
"dialog.directory.search.placeholder": "Ordner durchsuchen",
|
"dialog.directory.search.placeholder": "Ordner durchsuchen",
|
||||||
|
|||||||
@@ -306,7 +306,6 @@ export const dict = {
|
|||||||
"mcp.status.failed": "failed",
|
"mcp.status.failed": "failed",
|
||||||
"mcp.status.needs_auth": "needs auth",
|
"mcp.status.needs_auth": "needs auth",
|
||||||
"mcp.status.disabled": "disabled",
|
"mcp.status.disabled": "disabled",
|
||||||
"mcp.auth.clickToAuthenticate": "Click to authenticate",
|
|
||||||
|
|
||||||
"dialog.fork.empty": "No messages to fork from",
|
"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.title": "Read",
|
||||||
"settings.permissions.tool.read.description": "Reading a file (matches the file path)",
|
"settings.permissions.tool.read.description": "Reading a file (matches the file path)",
|
||||||
"settings.permissions.tool.edit.title": "Edit",
|
"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.title": "Glob",
|
||||||
"settings.permissions.tool.glob.description": "Match files using glob patterns",
|
"settings.permissions.tool.glob.description": "Match files using glob patterns",
|
||||||
"settings.permissions.tool.grep.title": "Grep",
|
"settings.permissions.tool.grep.title": "Grep",
|
||||||
|
|||||||
@@ -299,7 +299,6 @@ export const dict = {
|
|||||||
"mcp.status.connected": "conectado",
|
"mcp.status.connected": "conectado",
|
||||||
"mcp.status.failed": "fallido",
|
"mcp.status.failed": "fallido",
|
||||||
"mcp.status.needs_auth": "necesita auth",
|
"mcp.status.needs_auth": "necesita auth",
|
||||||
"mcp.auth.clickToAuthenticate": "Haz clic para autenticar",
|
|
||||||
"mcp.status.disabled": "deshabilitado",
|
"mcp.status.disabled": "deshabilitado",
|
||||||
|
|
||||||
"dialog.fork.empty": "No hay mensajes desde donde bifurcar",
|
"dialog.fork.empty": "No hay mensajes desde donde bifurcar",
|
||||||
|
|||||||
@@ -277,7 +277,6 @@ export const dict = {
|
|||||||
"mcp.status.connected": "connecté",
|
"mcp.status.connected": "connecté",
|
||||||
"mcp.status.failed": "échoué",
|
"mcp.status.failed": "échoué",
|
||||||
"mcp.status.needs_auth": "nécessite auth",
|
"mcp.status.needs_auth": "nécessite auth",
|
||||||
"mcp.auth.clickToAuthenticate": "Cliquez pour vous authentifier",
|
|
||||||
"mcp.status.disabled": "désactivé",
|
"mcp.status.disabled": "désactivé",
|
||||||
"dialog.fork.empty": "Aucun message à partir duquel bifurquer",
|
"dialog.fork.empty": "Aucun message à partir duquel bifurquer",
|
||||||
"dialog.directory.search.placeholder": "Rechercher des dossiers",
|
"dialog.directory.search.placeholder": "Rechercher des dossiers",
|
||||||
|
|||||||
@@ -275,7 +275,6 @@ export const dict = {
|
|||||||
"mcp.status.connected": "接続済み",
|
"mcp.status.connected": "接続済み",
|
||||||
"mcp.status.failed": "失敗",
|
"mcp.status.failed": "失敗",
|
||||||
"mcp.status.needs_auth": "認証が必要",
|
"mcp.status.needs_auth": "認証が必要",
|
||||||
"mcp.auth.clickToAuthenticate": "クリックして認証",
|
|
||||||
"mcp.status.disabled": "無効",
|
"mcp.status.disabled": "無効",
|
||||||
"dialog.fork.empty": "フォーク元のメッセージがありません",
|
"dialog.fork.empty": "フォーク元のメッセージがありません",
|
||||||
"dialog.directory.search.placeholder": "フォルダを検索",
|
"dialog.directory.search.placeholder": "フォルダを検索",
|
||||||
|
|||||||
@@ -275,7 +275,6 @@ export const dict = {
|
|||||||
"mcp.status.connected": "연결됨",
|
"mcp.status.connected": "연결됨",
|
||||||
"mcp.status.failed": "실패",
|
"mcp.status.failed": "실패",
|
||||||
"mcp.status.needs_auth": "인증 필요",
|
"mcp.status.needs_auth": "인증 필요",
|
||||||
"mcp.auth.clickToAuthenticate": "클릭하여 인증",
|
|
||||||
"mcp.status.disabled": "비활성화됨",
|
"mcp.status.disabled": "비활성화됨",
|
||||||
"dialog.fork.empty": "분기할 메시지 없음",
|
"dialog.fork.empty": "분기할 메시지 없음",
|
||||||
"dialog.directory.search.placeholder": "폴더 검색",
|
"dialog.directory.search.placeholder": "폴더 검색",
|
||||||
|
|||||||
@@ -302,7 +302,6 @@ export const dict = {
|
|||||||
"mcp.status.connected": "tilkoblet",
|
"mcp.status.connected": "tilkoblet",
|
||||||
"mcp.status.failed": "mislyktes",
|
"mcp.status.failed": "mislyktes",
|
||||||
"mcp.status.needs_auth": "trenger autentisering",
|
"mcp.status.needs_auth": "trenger autentisering",
|
||||||
"mcp.auth.clickToAuthenticate": "Klikk for å autentisere",
|
|
||||||
"mcp.status.disabled": "deaktivert",
|
"mcp.status.disabled": "deaktivert",
|
||||||
|
|
||||||
"dialog.fork.empty": "Ingen meldinger å forgrene fra",
|
"dialog.fork.empty": "Ingen meldinger å forgrene fra",
|
||||||
|
|||||||
@@ -277,7 +277,6 @@ export const dict = {
|
|||||||
"mcp.status.connected": "połączono",
|
"mcp.status.connected": "połączono",
|
||||||
"mcp.status.failed": "niepowodzenie",
|
"mcp.status.failed": "niepowodzenie",
|
||||||
"mcp.status.needs_auth": "wymaga autoryzacji",
|
"mcp.status.needs_auth": "wymaga autoryzacji",
|
||||||
"mcp.auth.clickToAuthenticate": "Kliknij, aby się uwierzytelnić",
|
|
||||||
"mcp.status.disabled": "wyłączone",
|
"mcp.status.disabled": "wyłączone",
|
||||||
"dialog.fork.empty": "Brak wiadomości do rozwidlenia",
|
"dialog.fork.empty": "Brak wiadomości do rozwidlenia",
|
||||||
"dialog.directory.search.placeholder": "Szukaj folderów",
|
"dialog.directory.search.placeholder": "Szukaj folderów",
|
||||||
|
|||||||
@@ -299,7 +299,6 @@ export const dict = {
|
|||||||
"mcp.status.connected": "подключено",
|
"mcp.status.connected": "подключено",
|
||||||
"mcp.status.failed": "ошибка",
|
"mcp.status.failed": "ошибка",
|
||||||
"mcp.status.needs_auth": "требуется авторизация",
|
"mcp.status.needs_auth": "требуется авторизация",
|
||||||
"mcp.auth.clickToAuthenticate": "Нажмите, чтобы авторизоваться",
|
|
||||||
"mcp.status.disabled": "отключено",
|
"mcp.status.disabled": "отключено",
|
||||||
|
|
||||||
"dialog.fork.empty": "Нет сообщений для ответвления",
|
"dialog.fork.empty": "Нет сообщений для ответвления",
|
||||||
|
|||||||
@@ -299,7 +299,6 @@ export const dict = {
|
|||||||
"mcp.status.connected": "เชื่อมต่อแล้ว",
|
"mcp.status.connected": "เชื่อมต่อแล้ว",
|
||||||
"mcp.status.failed": "ล้มเหลว",
|
"mcp.status.failed": "ล้มเหลว",
|
||||||
"mcp.status.needs_auth": "ต้องการการตรวจสอบสิทธิ์",
|
"mcp.status.needs_auth": "ต้องการการตรวจสอบสิทธิ์",
|
||||||
"mcp.auth.clickToAuthenticate": "คลิกเพื่อยืนยันตัวตน",
|
|
||||||
"mcp.status.disabled": "ปิดใช้งาน",
|
"mcp.status.disabled": "ปิดใช้งาน",
|
||||||
|
|
||||||
"dialog.fork.empty": "ไม่มีข้อความให้แตกแขนง",
|
"dialog.fork.empty": "ไม่มีข้อความให้แตกแขนง",
|
||||||
|
|||||||
@@ -304,7 +304,6 @@ export const dict = {
|
|||||||
"mcp.status.connected": "bağlı",
|
"mcp.status.connected": "bağlı",
|
||||||
"mcp.status.failed": "başarısız",
|
"mcp.status.failed": "başarısız",
|
||||||
"mcp.status.needs_auth": "kimlik doğrulama gerekli",
|
"mcp.status.needs_auth": "kimlik doğrulama gerekli",
|
||||||
"mcp.auth.clickToAuthenticate": "Kimlik doğrulamak için tıklayın",
|
|
||||||
"mcp.status.disabled": "devre dışı",
|
"mcp.status.disabled": "devre dışı",
|
||||||
|
|
||||||
"dialog.fork.empty": "Dallandırılacak mesaj yok",
|
"dialog.fork.empty": "Dallandırılacak mesaj yok",
|
||||||
|
|||||||
@@ -319,7 +319,6 @@ export const dict = {
|
|||||||
"mcp.status.connected": "已连接",
|
"mcp.status.connected": "已连接",
|
||||||
"mcp.status.failed": "失败",
|
"mcp.status.failed": "失败",
|
||||||
"mcp.status.needs_auth": "需要授权",
|
"mcp.status.needs_auth": "需要授权",
|
||||||
"mcp.auth.clickToAuthenticate": "点击进行授权",
|
|
||||||
"mcp.status.disabled": "已禁用",
|
"mcp.status.disabled": "已禁用",
|
||||||
|
|
||||||
"dialog.fork.empty": "没有可用于分叉的消息",
|
"dialog.fork.empty": "没有可用于分叉的消息",
|
||||||
|
|||||||
@@ -299,7 +299,6 @@ export const dict = {
|
|||||||
"mcp.status.connected": "已連線",
|
"mcp.status.connected": "已連線",
|
||||||
"mcp.status.failed": "失敗",
|
"mcp.status.failed": "失敗",
|
||||||
"mcp.status.needs_auth": "需要授權",
|
"mcp.status.needs_auth": "需要授權",
|
||||||
"mcp.auth.clickToAuthenticate": "點擊以進行授權",
|
|
||||||
"mcp.status.disabled": "已停用",
|
"mcp.status.disabled": "已停用",
|
||||||
|
|
||||||
"dialog.fork.empty": "沒有可用於分支的訊息",
|
"dialog.fork.empty": "沒有可用於分支的訊息",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.15.0",
|
"version": "1.14.49",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ export const config = {
|
|||||||
github: {
|
github: {
|
||||||
repoUrl: "https://github.com/anomalyco/opencode",
|
repoUrl: "https://github.com/anomalyco/opencode",
|
||||||
starsFormatted: {
|
starsFormatted: {
|
||||||
compact: "160K",
|
compact: "150K",
|
||||||
full: "160,000",
|
full: "150,000",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -22,8 +22,8 @@ export const config = {
|
|||||||
|
|
||||||
// Static stats (used on landing page)
|
// Static stats (used on landing page)
|
||||||
stats: {
|
stats: {
|
||||||
contributors: "900",
|
contributors: "850",
|
||||||
commits: "13,000",
|
commits: "11,000",
|
||||||
monthlyUsers: "7.5M",
|
monthlyUsers: "6.5M",
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.15.0",
|
"version": "1.14.49",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.15.0",
|
"version": "1.14.49",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.15.0",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.15.0",
|
"version": "1.14.49",
|
||||||
"name": "@opencode-ai/core",
|
"name": "@opencode-ai/core",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import { produce, type Draft } from "immer"
|
|||||||
import { ModelV2 } from "./model"
|
import { ModelV2 } from "./model"
|
||||||
import { PluginV2 } from "./plugin"
|
import { PluginV2 } from "./plugin"
|
||||||
import { ProviderV2 } from "./provider"
|
import { ProviderV2 } from "./provider"
|
||||||
import { Location } from "./location"
|
import { Instance } from "./instance"
|
||||||
import { EventV2 } from "./event"
|
|
||||||
|
|
||||||
type ProviderRecord = {
|
type ProviderRecord = {
|
||||||
provider: ProviderV2.Info
|
provider: ProviderV2.Info
|
||||||
@@ -25,15 +24,6 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundErr
|
|||||||
modelID: ModelV2.ID,
|
modelID: ModelV2.ID,
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export const Event = {
|
|
||||||
ModelUpdated: EventV2.define({
|
|
||||||
type: "catalog.model.updated",
|
|
||||||
schema: {
|
|
||||||
model: ModelV2.Info,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly provider: {
|
readonly provider: {
|
||||||
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info, ProviderNotFoundError>
|
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(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* Location.Service
|
yield* Instance.Service
|
||||||
let records = HashMap.empty<ProviderV2.ID, ProviderRecord>()
|
let records = HashMap.empty<ProviderV2.ID, ProviderRecord>()
|
||||||
let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined
|
let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const events = yield* EventV2.Service
|
|
||||||
|
|
||||||
const resolve = (model: ModelV2.Info) => {
|
const resolve = (model: ModelV2.Info) => {
|
||||||
const provider = Option.getOrThrow(HashMap.get(records, model.providerID)).provider
|
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 })
|
const updated = yield* plugin.trigger("model.update", {}, { model, cancel: false })
|
||||||
if (updated.cancel) return
|
if (updated.cancel) return
|
||||||
const next = new ModelV2.Info({ ...updated.model, id: modelID, providerID })
|
|
||||||
records = HashMap.set(records, providerID, {
|
records = HashMap.set(records, providerID, {
|
||||||
provider: record.provider,
|
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
|
return
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -266,4 +257,4 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
|
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"
|
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_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"]
|
const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
|
||||||
|
|
||||||
export const Flag = {
|
export const Flag = {
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT: process.env["OTEL_EXPORTER_OTLP_ENDPOINT"],
|
OTEL_EXPORTER_OTLP_ENDPOINT: process.env["OTEL_EXPORTER_OTLP_ENDPOINT"],
|
||||||
OTEL_EXPORTER_OTLP_HEADERS: process.env["OTEL_EXPORTER_OTLP_HEADERS"],
|
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_AUTO_HEAP_SNAPSHOT: truthy("OPENCODE_AUTO_HEAP_SNAPSHOT"),
|
||||||
OPENCODE_GIT_BASH_PATH: process.env["OPENCODE_GIT_BASH_PATH"],
|
OPENCODE_GIT_BASH_PATH: process.env["OPENCODE_GIT_BASH_PATH"],
|
||||||
OPENCODE_CONFIG: process.env["OPENCODE_CONFIG"],
|
OPENCODE_CONFIG: process.env["OPENCODE_CONFIG"],
|
||||||
@@ -22,29 +38,54 @@ export const Flag = {
|
|||||||
OPENCODE_DISABLE_TERMINAL_TITLE: truthy("OPENCODE_DISABLE_TERMINAL_TITLE"),
|
OPENCODE_DISABLE_TERMINAL_TITLE: truthy("OPENCODE_DISABLE_TERMINAL_TITLE"),
|
||||||
OPENCODE_SHOW_TTFD: truthy("OPENCODE_SHOW_TTFD"),
|
OPENCODE_SHOW_TTFD: truthy("OPENCODE_SHOW_TTFD"),
|
||||||
OPENCODE_PERMISSION: process.env["OPENCODE_PERMISSION"],
|
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_AUTOCOMPACT: truthy("OPENCODE_DISABLE_AUTOCOMPACT"),
|
||||||
OPENCODE_DISABLE_MODELS_FETCH: truthy("OPENCODE_DISABLE_MODELS_FETCH"),
|
OPENCODE_DISABLE_MODELS_FETCH: truthy("OPENCODE_DISABLE_MODELS_FETCH"),
|
||||||
OPENCODE_DISABLE_MOUSE: truthy("OPENCODE_DISABLE_MOUSE"),
|
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_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"],
|
||||||
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
|
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
|
||||||
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
|
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
|
||||||
|
OPENCODE_ENABLE_QUESTION_TOOL: truthy("OPENCODE_ENABLE_QUESTION_TOOL"),
|
||||||
|
|
||||||
// Experimental
|
// Experimental
|
||||||
|
OPENCODE_EXPERIMENTAL,
|
||||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_FILEWATCHER").pipe(
|
OPENCODE_EXPERIMENTAL_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_FILEWATCHER").pipe(
|
||||||
Config.withDefault(false),
|
Config.withDefault(false),
|
||||||
),
|
),
|
||||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER").pipe(
|
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER").pipe(
|
||||||
Config.withDefault(false),
|
Config.withDefault(false),
|
||||||
),
|
),
|
||||||
|
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
|
||||||
OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT:
|
OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT:
|
||||||
copy === undefined ? process.platform === "win32" : truthy("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_URL: process.env["OPENCODE_MODELS_URL"],
|
||||||
OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"],
|
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_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_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
|
||||||
OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"),
|
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
|
// Evaluated at access time (not module load) because tests, the CLI, and
|
||||||
// external tooling set these env vars at runtime.
|
// 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
|
if (evt.provider.id !== ProviderV2.ID.make("nvidia")) return
|
||||||
evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
|
evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||||
evt.provider.options.headers["X-Title"] = "opencode"
|
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 { 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 { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { Instance } from "@opencode-ai/core/instance"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
|
const instanceLayer = Layer.succeed(Instance.Service, Instance.Service.of({ directory: "test" }))
|
||||||
const it = testEffect(
|
const it = testEffect(Catalog.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer), Layer.provide(instanceLayer)))
|
||||||
Catalog.layer.pipe(
|
|
||||||
Layer.provideMerge(EventV2.defaultLayer),
|
|
||||||
Layer.provideMerge(PluginV2.defaultLayer),
|
|
||||||
Layer.provideMerge(locationLayer),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
describe("CatalogV2", () => {
|
describe("CatalogV2", () => {
|
||||||
it.effect("normalizes provider baseURL into endpoint url", () =>
|
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", () =>
|
it.effect("resolves unknown model endpoint from provider endpoint", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const catalog = yield* Catalog.Service
|
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* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* plugin.add(NvidiaPlugin)
|
yield* plugin.add(NvidiaPlugin)
|
||||||
@@ -34,60 +34,8 @@ describe("NvidiaPlugin", () => {
|
|||||||
Existing: "value",
|
Existing: "value",
|
||||||
"HTTP-Referer": "https://opencode.ai/",
|
"HTTP-Referer": "https://opencode.ai/",
|
||||||
"X-Title": "opencode",
|
"X-Title": "opencode",
|
||||||
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
|
|
||||||
})
|
})
|
||||||
expect(ignored.provider.options.headers).toEqual({})
|
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 { describe, expect } from "bun:test"
|
||||||
import { DateTime, Effect, Layer, Option } from "effect"
|
import { DateTime, Effect, Layer, Option } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
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 { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
|
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"
|
import { it, model, provider, withEnv } from "./provider-helper"
|
||||||
|
|
||||||
const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
|
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", () => {
|
describe("OpencodePlugin", () => {
|
||||||
it.effect("uses a public key and cancels paid models without credentials", () =>
|
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)
|
const selected = yield* catalog.model.small(providerID)
|
||||||
|
|
||||||
expect(Option.getOrUndefined(selected)?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
|
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",
|
"name": "@opencode-ai/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.15.0",
|
"version": "1.14.49",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://opencode.ai",
|
"homepage": "https://opencode.ai",
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ async function checkMacosApp(appName: string) {
|
|||||||
async function resolveWindowsAppPath(appName: string): Promise<string | null> {
|
async function resolveWindowsAppPath(appName: string): Promise<string | null> {
|
||||||
let output: string
|
let output: string
|
||||||
try {
|
try {
|
||||||
output = await execFilePromise("where", [appName]).then((r) => r.stdout.toString())
|
output = execFilePromise("where", [appName]).toString()
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
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 const init: typeof import("../../../opencode/dist/types/src/node").Log.init
|
||||||
}
|
}
|
||||||
export namespace Database {
|
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 const Client: typeof import("../../../opencode/dist/types/src/node").Database.Client
|
||||||
}
|
}
|
||||||
export namespace JsonMigration {
|
export namespace JsonMigration {
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ export function createMainWindow() {
|
|||||||
width: state.width,
|
width: state.width,
|
||||||
height: state.height,
|
height: state.height,
|
||||||
show: false,
|
show: false,
|
||||||
autoHideMenuBar: true,
|
|
||||||
title: "OpenCode",
|
title: "OpenCode",
|
||||||
icon: iconPath(),
|
icon: iconPath(),
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
@@ -143,7 +142,6 @@ export function createLoadingWindow() {
|
|||||||
resizable: false,
|
resizable: false,
|
||||||
center: true,
|
center: true,
|
||||||
show: true,
|
show: true,
|
||||||
autoHideMenuBar: true,
|
|
||||||
icon: iconPath(),
|
icon: iconPath(),
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}),
|
...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.15.0",
|
"version": "1.14.49",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
id = "opencode"
|
id = "opencode"
|
||||||
name = "OpenCode"
|
name = "OpenCode"
|
||||||
description = "The open source coding agent."
|
description = "The open source coding agent."
|
||||||
version = "1.15.0"
|
version = "1.14.49"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Anomaly"]
|
authors = ["Anomaly"]
|
||||||
repository = "https://github.com/anomalyco/opencode"
|
repository = "https://github.com/anomalyco/opencode"
|
||||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
|||||||
icon = "./icons/opencode.svg"
|
icon = "./icons/opencode.svg"
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-aarch64]
|
[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"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-x86_64]
|
[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"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-aarch64]
|
[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"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-x86_64]
|
[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"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.windows-x86_64]
|
[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"
|
cmd = "./opencode.exe"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.15.0",
|
"version": "1.14.49",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -166,11 +166,11 @@ import { Effect } from "effect"
|
|||||||
|
|
||||||
const audit = Effect.gen(function* () {
|
const audit = Effect.gen(function* () {
|
||||||
const cassettes = yield* HttpRecorder.Cassette.Service
|
const cassettes = yield* HttpRecorder.Cassette.Service
|
||||||
const names = yield* cassettes.list()
|
const entries = yield* cassettes.list()
|
||||||
const issues = yield* Effect.forEach(names, (name) =>
|
const issues = yield* Effect.forEach(entries, (entry) =>
|
||||||
cassettes
|
cassettes
|
||||||
.read(name)
|
.read(entry.name)
|
||||||
.pipe(Effect.map((interactions) => ({ name, findings: HttpRecorder.secretFindings(interactions) }))),
|
.pipe(Effect.map((interactions) => ({ name: entry.name, findings: HttpRecorder.secretFindings(interactions) }))),
|
||||||
)
|
)
|
||||||
return issues.filter((i) => i.findings.length > 0)
|
return issues.filter((i) => i.findings.length > 0)
|
||||||
})
|
})
|
||||||
@@ -196,13 +196,14 @@ type RecordReplayOptions = {
|
|||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
| -------------- | --------------------------------------------------------------------------- |
|
| -------------- | -------------------------------------------------------------------------------- |
|
||||||
| `effect.ts` | `cassetteLayer` / `recordingLayer` — the `HttpClient` adapter. |
|
| `effect.ts` | `cassetteLayer` / `recordingLayer` — the `HttpClient` adapter. |
|
||||||
| `websocket.ts` | `makeWebSocketExecutor` — WebSocket record/replay. |
|
| `websocket.ts` | `makeWebSocketExecutor` — WebSocket record/replay. |
|
||||||
| `cassette.ts` | `Cassette.Service` — `fileSystem` / `memory` adapters, error types. |
|
| `cassette.ts` | `Cassette.Service` — reads/writes cassette files, accumulates state. |
|
||||||
| `recorder.ts` | Shared transport plumbing: `resolveAutoMode`, `ReplayState`. |
|
| `recorder.ts` | Shared transport plumbing: `UnsafeCassetteError`, `appendOrFail`, `ReplayState`. |
|
||||||
| `redactor.ts` | Composable `Redactor` — headers, url, body redaction. |
|
| `redactor.ts` | Composable `Redactor` — headers, url, body redaction. |
|
||||||
| `redaction.ts` | Lower-level header/URL primitives + secret pattern detection. |
|
| `redaction.ts` | Lower-level header/URL primitives + secret pattern detection. |
|
||||||
| `schema.ts` | Effect Schema definitions for the cassette JSON format. |
|
| `schema.ts` | Effect Schema definitions for the cassette JSON format. |
|
||||||
| `matching.ts` | Request matcher, canonicalization, sequential cursor, mismatch diagnostics. |
|
| `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",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.15.0",
|
"version": "1.14.49",
|
||||||
"name": "@opencode-ai/http-recorder",
|
"name": "@opencode-ai/http-recorder",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
|
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
|
||||||
import * as fs from "node:fs"
|
import * as fs from "node:fs"
|
||||||
import * as path from "node:path"
|
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"
|
import { decodeCassette, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema"
|
||||||
|
|
||||||
const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
|
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", {
|
export interface AppendResult {
|
||||||
cassetteName: Schema.String,
|
readonly findings: ReadonlyArray<SecretFinding>
|
||||||
findings: Schema.Array(SecretFindingSchema),
|
|
||||||
}) {
|
|
||||||
override get message() {
|
|
||||||
return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings
|
|
||||||
.map((finding) => `${finding.path} (${finding.reason})`)
|
|
||||||
.join(", ")}`
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly read: (name: string) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError>
|
readonly read: (name: string) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError>
|
||||||
readonly append: (
|
readonly append: (name: string, interaction: Interaction, metadata?: CassetteMetadata) => Effect.Effect<AppendResult>
|
||||||
name: string,
|
|
||||||
interaction: Interaction,
|
|
||||||
metadata?: CassetteMetadata,
|
|
||||||
) => Effect.Effect<void, UnsafeCassetteError>
|
|
||||||
readonly exists: (name: string) => Effect.Effect<boolean>
|
readonly exists: (name: string) => Effect.Effect<boolean>
|
||||||
readonly list: () => Effect.Effect<ReadonlyArray<string>>
|
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 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 = (
|
export const fileSystem = (
|
||||||
options: { readonly directory?: string } = {},
|
options: { readonly directory?: string } = {},
|
||||||
): Layer.Layer<Service, never, FileSystem.FileSystem> =>
|
): Layer.Layer<Service, never, FileSystem.FileSystem> =>
|
||||||
@@ -106,9 +92,11 @@ export const fileSystem = (
|
|||||||
entry.findings.push(...secretFindings(interaction))
|
entry.findings.push(...secretFindings(interaction))
|
||||||
const cassette = buildCassette(name, entry.interactions, metadata)
|
const cassette = buildCassette(name, entry.interactions, metadata)
|
||||||
const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})]
|
const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})]
|
||||||
yield* failIfUnsafe(name, findings)
|
if (findings.length === 0) {
|
||||||
yield* ensureDirectory(name)
|
yield* ensureDirectory(name)
|
||||||
yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie)
|
yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie)
|
||||||
|
}
|
||||||
|
return { findings }
|
||||||
}),
|
}),
|
||||||
exists: (name) =>
|
exists: (name) =>
|
||||||
fs.access(cassettePath(name)).pipe(
|
fs.access(cassettePath(name)).pipe(
|
||||||
@@ -145,17 +133,17 @@ export const memory = (initial: Record<string, ReadonlyArray<Interaction>> = {})
|
|||||||
stored.has(name)
|
stored.has(name)
|
||||||
? Effect.succeed(stored.get(name) ?? [])
|
? Effect.succeed(stored.get(name) ?? [])
|
||||||
: Effect.fail(new CassetteNotFoundError({ cassetteName: name })),
|
: Effect.fail(new CassetteNotFoundError({ cassetteName: name })),
|
||||||
append: (name, interaction, metadata) => {
|
append: (name, interaction, metadata) =>
|
||||||
const existing = stored.get(name)
|
Effect.sync(() => {
|
||||||
if (existing) existing.push(interaction)
|
const existing = stored.get(name)
|
||||||
else stored.set(name, [interaction])
|
if (existing) existing.push(interaction)
|
||||||
const existingFindings = accumulatedFindings.get(name)
|
else stored.set(name, [interaction])
|
||||||
const findings = existingFindings ?? []
|
const findings = accumulatedFindings.get(name)
|
||||||
if (!existingFindings) accumulatedFindings.set(name, findings)
|
if (findings) findings.push(...secretFindings(interaction))
|
||||||
findings.push(...secretFindings(interaction))
|
else accumulatedFindings.set(name, [...secretFindings(interaction)])
|
||||||
if (metadata) findings.push(...secretFindings({ name, ...metadata }))
|
if (metadata) accumulatedFindings.get(name)!.push(...secretFindings({ name, ...metadata }))
|
||||||
return failIfUnsafe(name, findings)
|
return { findings: accumulatedFindings.get(name) ?? [] }
|
||||||
},
|
}),
|
||||||
exists: (name) => Effect.sync(() => stored.has(name)),
|
exists: (name) => Effect.sync(() => stored.has(name)),
|
||||||
list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()),
|
list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from "effect/unstable/http"
|
} from "effect/unstable/http"
|
||||||
import * as CassetteService from "./cassette"
|
import * as CassetteService from "./cassette"
|
||||||
import { defaultMatcher, selectSequential, type RequestMatcher } from "./matching"
|
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 { defaults, type Redactor } from "./redactor"
|
||||||
import { redactUrl } from "./redaction"
|
import { redactUrl } from "./redaction"
|
||||||
import { httpInteractions, type CassetteMetadata, type HttpInteraction, type ResponseSnapshot } from "./schema"
|
import { httpInteractions, type CassetteMetadata, type HttpInteraction, type ResponseSnapshot } from "./schema"
|
||||||
@@ -100,11 +100,9 @@ export const recordingLayer = (
|
|||||||
...captured,
|
...captured,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
yield* cassetteService
|
yield* appendOrFail(cassetteService, name, interaction, options.metadata).pipe(
|
||||||
.append(name, interaction, options.metadata)
|
Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(transportError(request, error.message))),
|
||||||
.pipe(
|
)
|
||||||
Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(transportError(request, error.message))),
|
|
||||||
)
|
|
||||||
return HttpClientResponse.fromWeb(
|
return HttpClientResponse.fromWeb(
|
||||||
request,
|
request,
|
||||||
new Response(decodeResponseBody(interaction.response), interaction.response),
|
new Response(decodeResponseBody(interaction.response), interaction.response),
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ export type {
|
|||||||
WebSocketFrame,
|
WebSocketFrame,
|
||||||
WebSocketInteraction,
|
WebSocketInteraction,
|
||||||
} from "./schema"
|
} from "./schema"
|
||||||
export { CassetteNotFoundError, hasCassetteSync, UnsafeCassetteError } from "./cassette"
|
export { CassetteNotFoundError, hasCassetteSync } from "./cassette"
|
||||||
export { defaultMatcher, type RequestMatcher } from "./matching"
|
export { defaultMatcher, type RequestMatcher } from "./matching"
|
||||||
export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction"
|
export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction"
|
||||||
|
export { UnsafeCassetteError } from "./recorder"
|
||||||
export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./effect"
|
export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./effect"
|
||||||
export {
|
export {
|
||||||
makeWebSocketExecutor,
|
makeWebSocketExecutor,
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const canonicalSnapshot = (snapshot: RequestSnapshot): string =>
|
|||||||
export const defaultMatcher: RequestMatcher = (incoming, recorded) =>
|
export const defaultMatcher: RequestMatcher = (incoming, recorded) =>
|
||||||
canonicalSnapshot(incoming) === canonicalSnapshot(recorded)
|
canonicalSnapshot(incoming) === canonicalSnapshot(recorded)
|
||||||
|
|
||||||
export const safeText = (value: unknown) => {
|
const safeText = (value: unknown) => {
|
||||||
if (value === undefined) return "undefined"
|
if (value === undefined) return "undefined"
|
||||||
if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
|
if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
|
||||||
const text = JSON.stringify(value)
|
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 * as CassetteService from "./cassette"
|
||||||
import type { CassetteNotFoundError } 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 isCI = () => {
|
||||||
const value = process.env.CI
|
const value = process.env.CI
|
||||||
return value !== undefined && value !== "" && value !== "false" && value !== "0"
|
return value !== undefined && value !== "" && value !== "false" && value !== "0"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const resolveAutoMode = (
|
export const resolveAutoMode = (cassette: CassetteService.Interface, name: string): Effect.Effect<ResolvedMode> =>
|
||||||
cassette: CassetteService.Interface,
|
|
||||||
name: string,
|
|
||||||
): Effect.Effect<"record" | "replay" | "passthrough"> =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (isCI()) return "replay"
|
if (isCI()) return "replay"
|
||||||
return (yield* cassette.exists(name)) ? "replay" : "record"
|
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> {
|
export interface ReplayState<T> {
|
||||||
readonly load: Effect.Effect<ReadonlyArray<T>, CassetteNotFoundError>
|
readonly load: Effect.Effect<ReadonlyArray<T>, CassetteNotFoundError>
|
||||||
readonly cursor: Effect.Effect<number>
|
readonly cursor: Effect.Effect<number>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { Schema } from "effect"
|
|
||||||
|
|
||||||
export const REDACTED = "[REDACTED]"
|
export const REDACTED = "[REDACTED]"
|
||||||
|
|
||||||
const DEFAULT_REDACT_HEADERS = [
|
const DEFAULT_REDACT_HEADERS = [
|
||||||
@@ -97,6 +95,8 @@ export const redactHeaders = (
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { Schema } from "effect"
|
||||||
|
|
||||||
export const SecretFindingSchema = Schema.Struct({
|
export const SecretFindingSchema = Schema.Struct({
|
||||||
path: Schema.String,
|
path: Schema.String,
|
||||||
reason: Schema.String,
|
reason: Schema.String,
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Effect, Option, Ref, Scope, Stream } from "effect"
|
import { Effect, Option, Ref, Scope, Stream } from "effect"
|
||||||
import type { Headers } from "effect/unstable/http"
|
import type { Headers } from "effect/unstable/http"
|
||||||
import * as CassetteService from "./cassette"
|
import * as CassetteService from "./cassette"
|
||||||
import { canonicalizeJson, decodeJson, safeText } from "./matching"
|
import { canonicalizeJson, decodeJson } from "./matching"
|
||||||
import { makeReplayState, resolveAutoMode } from "./recorder"
|
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder"
|
||||||
import type { RecordReplayMode } from "./effect"
|
import type { RecordReplayMode } from "./effect"
|
||||||
import { redactUrl } from "./redaction"
|
|
||||||
import { defaults, type Redactor } from "./redactor"
|
import { defaults, type Redactor } from "./redactor"
|
||||||
import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame } from "./schema"
|
import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame } from "./schema"
|
||||||
|
|
||||||
@@ -54,7 +53,7 @@ const decodeFrameText = (frame: WebSocketFrame) =>
|
|||||||
const assertEqual = (message: string, actual: unknown, expected: unknown) =>
|
const assertEqual = (message: string, actual: unknown, expected: unknown) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
if (JSON.stringify(actual) === JSON.stringify(expected)) return
|
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 })
|
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) => {
|
const compareClientMessage = (actual: string, expected: WebSocketFrame | undefined, index: number, asJson: boolean) => {
|
||||||
if (!expected)
|
if (!expected)
|
||||||
return Effect.sync(() => {
|
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)
|
const expectedText = decodeFrameText(expected)
|
||||||
if (!asJson) return assertEqual(`WebSocket client frame ${index + 1}`, actual, expectedText)
|
if (!asJson) return assertEqual(`WebSocket client frame ${index + 1}`, actual, expectedText)
|
||||||
@@ -99,13 +98,12 @@ export const makeWebSocketExecutor = <E>(
|
|||||||
const closeOnce = Effect.gen(function* () {
|
const closeOnce = Effect.gen(function* () {
|
||||||
if (yield* Ref.getAndSet(closed, true)) return
|
if (yield* Ref.getAndSet(closed, true)) return
|
||||||
yield* connection.close
|
yield* connection.close
|
||||||
yield* options.cassette
|
yield* appendOrFail(
|
||||||
.append(
|
options.cassette,
|
||||||
options.name,
|
options.name,
|
||||||
{ transport: "websocket", open: openSnapshot(request), client, server },
|
{ transport: "websocket", open: openSnapshot(request), client, server },
|
||||||
options.metadata,
|
options.metadata,
|
||||||
)
|
).pipe(Effect.orDie)
|
||||||
.pipe(Effect.orDie)
|
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
sendText: (message) =>
|
sendText: (message) =>
|
||||||
@@ -113,7 +111,10 @@ export const makeWebSocketExecutor = <E>(
|
|||||||
.sendText(message)
|
.sendText(message)
|
||||||
.pipe(Effect.tap(() => Effect.sync(() => client.push(encodeFrame(message))))),
|
.pipe(Effect.tap(() => Effect.sync(() => client.push(encodeFrame(message))))),
|
||||||
messages: connection.messages.pipe(
|
messages: connection.messages.pipe(
|
||||||
Stream.tap((message) => Effect.sync(() => server.push(encodeFrame(message)))),
|
Stream.map((message) => {
|
||||||
|
server.push(encodeFrame(message))
|
||||||
|
return message
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
close: closeOnce,
|
close: closeOnce,
|
||||||
}
|
}
|
||||||
@@ -129,22 +130,20 @@ export const makeWebSocketExecutor = <E>(
|
|||||||
const interactions = yield* replay.load.pipe(Effect.orDie)
|
const interactions = yield* replay.load.pipe(Effect.orDie)
|
||||||
const index = yield* replay.cursor
|
const index = yield* replay.cursor
|
||||||
const interaction = interactions[index]
|
const interaction = interactions[index]
|
||||||
if (!interaction)
|
if (!interaction) return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${request.url}`))
|
||||||
return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${redactUrl(request.url)}`))
|
|
||||||
yield* replay.advance
|
yield* replay.advance
|
||||||
yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request), interaction.open)
|
yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request), interaction.open)
|
||||||
const messageIndex = yield* Ref.make(0)
|
const messageIndex = yield* Ref.make(0)
|
||||||
return {
|
return {
|
||||||
sendText: (message) =>
|
sendText: (message) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const current = yield* Ref.get(messageIndex)
|
const current = yield* Ref.getAndUpdate(messageIndex, (value) => value + 1)
|
||||||
yield* compareClientMessage(
|
yield* compareClientMessage(
|
||||||
message,
|
message,
|
||||||
interaction.client[current],
|
interaction.client[current],
|
||||||
current,
|
current,
|
||||||
options.compareClientMessagesAsJson === true,
|
options.compareClientMessagesAsJson === true,
|
||||||
)
|
)
|
||||||
yield* Ref.update(messageIndex, (value) => value + 1)
|
|
||||||
}),
|
}),
|
||||||
messages: Stream.fromIterable(interaction.server).pipe(Stream.map(decodeFrameMessage)),
|
messages: Stream.fromIterable(interaction.server).pipe(Stream.map(decodeFrameMessage)),
|
||||||
close: Effect.gen(function* () {
|
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",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.15.0",
|
"version": "1.14.49",
|
||||||
"name": "@opencode-ai/llm",
|
"name": "@opencode-ai/llm",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"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.
|
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",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.15.0",
|
"version": "1.14.49",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -32,6 +32,11 @@
|
|||||||
"bun": "./src/pty/pty.bun.ts",
|
"bun": "./src/pty/pty.bun.ts",
|
||||||
"node": "./src/pty/pty.node.ts",
|
"node": "./src/pty/pty.node.ts",
|
||||||
"default": "./src/pty/pty.bun.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": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -244,7 +244,6 @@ for (const item of targets) {
|
|||||||
{
|
{
|
||||||
name,
|
name,
|
||||||
version: Script.version,
|
version: Script.version,
|
||||||
preferUnplugged: true,
|
|
||||||
os: [item.os],
|
os: [item.os],
|
||||||
cpu: [item.arch],
|
cpu: [item.arch],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,189 +1,102 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import childProcess from "child_process"
|
|
||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
import os from "os"
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { createRequire } from "module"
|
import os from "os"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
|
import { createRequire } from "module"
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8"))
|
|
||||||
|
|
||||||
const platformMap = {
|
function detectPlatformAndArch() {
|
||||||
darwin: "darwin",
|
// Map platform names
|
||||||
linux: "linux",
|
let platform
|
||||||
win32: "windows",
|
switch (os.platform()) {
|
||||||
}
|
case "darwin":
|
||||||
const archMap = {
|
platform = "darwin"
|
||||||
x64: "x64",
|
break
|
||||||
arm64: "arm64",
|
case "linux":
|
||||||
arm: "arm",
|
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()
|
function findBinary() {
|
||||||
const arch = archMap[os.arch()] ?? os.arch()
|
const { platform, arch } = detectPlatformAndArch()
|
||||||
const base = `opencode-${platform}-${arch}`
|
const packageName = `opencode-${platform}-${arch}`
|
||||||
const sourceBinary = platform === "windows" ? "opencode.exe" : "opencode"
|
const binaryName = platform === "windows" ? "opencode.exe" : "opencode"
|
||||||
const targetBinary = path.join(__dirname, "bin", "opencode.exe")
|
|
||||||
|
|
||||||
function supportsAvx2() {
|
try {
|
||||||
if (arch !== "x64") return false
|
// 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 {
|
try {
|
||||||
return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8"))
|
fs.linkSync(binaryPath, target)
|
||||||
} catch {
|
} 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 {
|
try {
|
||||||
main()
|
void main()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error.message)
|
console.error("Postinstall script error:", error.message)
|
||||||
process.exit(1)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,32 +32,22 @@ console.log("binaries", binaries)
|
|||||||
const version = Object.values(binaries)[0]
|
const version = Object.values(binaries)[0]
|
||||||
|
|
||||||
await $`mkdir -p ./dist/${pkg.name}`
|
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 $`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}/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(
|
await Bun.file(`./dist/${pkg.name}/package.json`).write(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
name: pkg.name + "-ai",
|
name: pkg.name + "-ai",
|
||||||
bin: {
|
bin: {
|
||||||
[pkg.name]: `./bin/${pkg.name}.exe`,
|
[pkg.name]: `./bin/${pkg.name}`,
|
||||||
},
|
},
|
||||||
scripts: {
|
scripts: {
|
||||||
postinstall: "node ./postinstall.mjs",
|
postinstall: "bun ./postinstall.mjs || node ./postinstall.mjs",
|
||||||
},
|
},
|
||||||
version: version,
|
version: version,
|
||||||
license: pkg.license,
|
license: pkg.license,
|
||||||
os: ["darwin", "linux", "win32"],
|
|
||||||
cpu: ["arm64", "x64"],
|
|
||||||
optionalDependencies: binaries,
|
optionalDependencies: binaries,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Current status on this branch:
|
|||||||
|
|
||||||
- `src/` has 5 `makeRuntime(...)` call sites total.
|
- `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`.
|
- 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`.
|
- 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:
|
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/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/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
|
## 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`
|
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`
|
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
|
## 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
|
[`EffectBridge`](../../src/effect/bridge.ts) is the sanctioned helper for
|
||||||
Promise/callback interop that needs to preserve instance/workspace context.
|
Promise/callback interop that needs to preserve instance/workspace context.
|
||||||
It preserves explicit `InstanceRef` / `WorkspaceRef` context for effects run
|
Keep it, but reduce its dependency on legacy `Instance.current` /
|
||||||
through the bridge. Plain JS callbacks that need instance data should receive
|
`Instance.restore` over time.
|
||||||
that data explicitly.
|
|
||||||
|
`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
|
## 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.
|
End state:
|
||||||
- `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.
|
|
||||||
|
|
||||||
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.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.
|
- [ ] `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
|
## Notes
|
||||||
|
|
||||||
- Prefer small, semantics-preserving config migrations. Config precedence, legacy key migration, and plugin origin tracking are easy to break accidentally.
|
- 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.
|
explicit and testable instead of mutable module state.
|
||||||
Shrinks: [`global.ts`](../../../core/src/global.ts) import-time side
|
Shrinks: [`global.ts`](../../../core/src/global.ts) import-time side
|
||||||
effects, mutable `Global.Path` overrides, and its `Flag` dependency.
|
effects, mutable `Global.Path` overrides, and its `Flag` dependency.
|
||||||
- `INST` Instance context — keep project context explicit through Effect refs
|
- `INST` Instance shim — remove ambient `Instance` usage and old ALS
|
||||||
and bridge boundaries.
|
access patterns.
|
||||||
|
Shrinks: [`src/project/instance.ts`](../../src/project/instance.ts).
|
||||||
- `BRIDGE` Promise/callback interop — keep bridge helpers, but reduce
|
- `BRIDGE` Promise/callback interop — keep bridge helpers, but reduce
|
||||||
legacy ALS coupling.
|
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
|
- `PROC` AppProcess migration — prefer `AppProcess.Service` over raw
|
||||||
process wrappers.
|
process wrappers.
|
||||||
Shrinks: direct spawn callsites and legacy process helpers.
|
Shrinks: direct spawn callsites and legacy process helpers.
|
||||||
@@ -92,16 +94,19 @@ shapes and sometimes collapse rich errors into opaque strings.
|
|||||||
|
|
||||||
### Problems
|
### Problems
|
||||||
|
|
||||||
- Some expected service failures still use `NamedError.create(...)` or
|
- Some expected service failures still use `NamedError.create(...)`.
|
||||||
collapse to `Effect.die(...)`. The storage/worktree/provider-auth
|
- Some expected service failures still become `Effect.die(...)`, which
|
||||||
conversions are done; an inventory sweep is needed for the rest.
|
makes them defects instead of typed, recoverable failures.
|
||||||
- HTTP error middleware still guesses status codes from error names —
|
- CLI and HTTP boundaries can render structured errors as generic
|
||||||
some entries (e.g. storage `NotFound`, provider auth) can now be
|
`Error: SomeName` output.
|
||||||
removed, but the middleware overall has not shrunk.
|
- 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
|
- Route handlers and route groups do not consistently declare the public
|
||||||
error body they intend to expose.
|
error body they intend to expose.
|
||||||
- Repeated route error translations do not yet have a clear home: some
|
- Repeated route error translations do not yet have a clear home: some
|
||||||
should stay inline, some deserve tiny shared mapper helpers.
|
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
|
### 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
|
- Generic HTTP middleware should shrink; it should not accumulate more
|
||||||
name-based domain knowledge.
|
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
|
### 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
|
- [ ] `HTTP-2` Audit one route group for explicit error contracts and
|
||||||
decide which mappings stay inline vs. shared helper.
|
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
|
## P1: Tests
|
||||||
|
|
||||||
@@ -172,24 +163,26 @@ Recently completed:
|
|||||||
- [x] Built-in websearch provider selection uses the same runtime flags as
|
- [x] Built-in websearch provider selection uses the same runtime flags as
|
||||||
tool visibility.
|
tool visibility.
|
||||||
- [x] Removed global default-plugin disabling from test preload.
|
- [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
|
```text
|
||||||
callsites still import [`flag.ts`](../../../core/src/flag/flag.ts).
|
RF-1 scout consumers ─┐
|
||||||
Decide per-callsite whether to route through RuntimeFlags, accept
|
├─ can run in parallel
|
||||||
as legitimate env/config boundary, or migrate to typed `Config`.
|
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
|
- [ ] Delete [`test/fixture/flag.ts`](../../test/fixture/flag.ts) once
|
||||||
tests no longer mutate `Flag`.
|
tests no longer mutate `Flag`.
|
||||||
- [ ] Delete [`flag.ts`](../../../core/src/flag/flag.ts) once no packages
|
- [ ] Delete [`flag.ts`](../../../core/src/flag/flag.ts) once no packages
|
||||||
@@ -219,13 +212,17 @@ Next PR candidates:
|
|||||||
|
|
||||||
## P4: Instance And Bridge
|
## 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.
|
- Keep a sanctioned bridge for Promise/callback boundaries.
|
||||||
- Plain JavaScript callback boundaries use `EffectBridge` or explicit context arguments.
|
- Reduce bridge dependence on legacy `Instance.restore` / `Instance.current`.
|
||||||
- Runtime entrypoints must provide refs explicitly when they are instance-scoped.
|
- 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
|
## Lower Priority Tracks
|
||||||
|
|
||||||
|
|||||||
@@ -39,9 +39,10 @@ import { Filesystem } from "@/util/filesystem"
|
|||||||
import { Hash } from "@opencode-ai/core/util/hash"
|
import { Hash } from "@opencode-ai/core/util/hash"
|
||||||
import { ACPSessionManager } from "./session"
|
import { ACPSessionManager } from "./session"
|
||||||
import type { ACPConfig } from "./types"
|
import type { ACPConfig } from "./types"
|
||||||
import { ACPRuntime } from "./runtime"
|
|
||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
import { ModelID, ProviderID } from "../provider/schema"
|
import { ModelID, ProviderID } from "../provider/schema"
|
||||||
|
import { Agent as AgentModule } from "../agent/agent"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
import { MessageV2 } from "@/session/message-v2"
|
import { MessageV2 } from "@/session/message-v2"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
@@ -1093,7 +1094,7 @@ export class Agent implements ACPAgent {
|
|||||||
|
|
||||||
const currentModeId = await (async () => {
|
const currentModeId = await (async () => {
|
||||||
if (!availableModes.length) return undefined
|
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
|
const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgent.name)?.id ?? availableModes[0].id
|
||||||
this.sessionManager.setMode(sessionId, resolvedModeId)
|
this.sessionManager.setMode(sessionId, resolvedModeId)
|
||||||
return resolvedModeId
|
return resolvedModeId
|
||||||
@@ -1327,7 +1328,8 @@ export class Agent implements ACPAgent {
|
|||||||
if (!current) {
|
if (!current) {
|
||||||
this.sessionManager.setModel(session.id, model)
|
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<
|
const parts: Array<
|
||||||
| { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
|
| { 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 { Schema } from "effect"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
|
||||||
|
|
||||||
export type Definition<Type extends string = string, Properties extends Schema.Top = Schema.Top> = {
|
export type Definition<Type extends string = string, Properties extends Schema.Top = Schema.Top> = {
|
||||||
type: Type
|
type: Type
|
||||||
@@ -18,28 +17,16 @@ export function define<Type extends string, Properties extends Schema.Top>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function effectPayloads() {
|
export function effectPayloads() {
|
||||||
return [
|
return registry
|
||||||
...registry
|
.entries()
|
||||||
.entries()
|
.map(([type, def]) =>
|
||||||
.map(([type, def]) =>
|
Schema.Struct({
|
||||||
Schema.Struct({
|
id: Schema.String,
|
||||||
id: Schema.String,
|
type: Schema.Literal(type),
|
||||||
type: Schema.Literal(type),
|
properties: def.properties,
|
||||||
properties: def.properties,
|
}).annotate({ identifier: `Event.${type}` }),
|
||||||
}).annotate({ identifier: `Event.${type}` }),
|
)
|
||||||
)
|
.toArray()
|
||||||
.toArray(),
|
|
||||||
...EventV2.registry
|
|
||||||
.values()
|
|
||||||
.map((definition) =>
|
|
||||||
Schema.Struct({
|
|
||||||
id: Schema.String,
|
|
||||||
type: Schema.Literal(definition.type),
|
|
||||||
properties: definition.data,
|
|
||||||
}).annotate({ identifier: `Event.${definition.type}` }),
|
|
||||||
)
|
|
||||||
.toArray(),
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export * as BusEvent from "./bus-event"
|
export * as BusEvent from "./bus-event"
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
|
import { Instance } from "../project/instance"
|
||||||
import { InstanceRuntime } from "../project/instance-runtime"
|
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>) {
|
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
|
||||||
const ctx = await InstanceRuntime.load({ directory })
|
return WithInstance.provide({
|
||||||
try {
|
directory,
|
||||||
return await context.provide(ctx, cb)
|
fn: async () => {
|
||||||
} finally {
|
try {
|
||||||
await InstanceRuntime.disposeInstance(ctx)
|
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 }) => {
|
handler: async (args: { query?: string; format: string }) => {
|
||||||
const query = args.query as string | undefined
|
const query = args.query as string | undefined
|
||||||
if (query) {
|
if (query) {
|
||||||
const db = new BunDatabase(Database.getPath(), { readonly: true })
|
const db = new BunDatabase(Database.Path, { readonly: true })
|
||||||
try {
|
try {
|
||||||
const result = db.query(query).all() as Record<string, unknown>[]
|
const result = db.query(query).all() as Record<string, unknown>[]
|
||||||
if (args.format === "json") {
|
if (args.format === "json") {
|
||||||
@@ -47,7 +47,7 @@ const QueryCommand = cmd({
|
|||||||
db.close()
|
db.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const child = spawn("sqlite3", [Database.getPath()], {
|
const child = spawn("sqlite3", [Database.Path], {
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
})
|
})
|
||||||
await new Promise((resolve) => child.on("close", resolve))
|
await new Promise((resolve) => child.on("close", resolve))
|
||||||
@@ -58,7 +58,7 @@ const PathCommand = cmd({
|
|||||||
command: "path",
|
command: "path",
|
||||||
describe: "print the database path",
|
describe: "print the database path",
|
||||||
handler: () => {
|
handler: () => {
|
||||||
console.log(Database.getPath())
|
console.log(Database.Path)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ const MigrateCommand = cmd({
|
|||||||
command: "migrate",
|
command: "migrate",
|
||||||
describe: "migrate JSON data to SQLite (merges with existing data)",
|
describe: "migrate JSON data to SQLite (merges with existing data)",
|
||||||
handler: async () => {
|
handler: async () => {
|
||||||
const sqlite = new BunDatabase(Database.getPath())
|
const sqlite = new BunDatabase(Database.Path)
|
||||||
const tty = process.stderr.isTTY
|
const tty = process.stderr.isTTY
|
||||||
const width = 36
|
const width = 36
|
||||||
const orange = "\x1b[38;5;214m"
|
const orange = "\x1b[38;5;214m"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Permission } from "../../../permission"
|
|||||||
import { iife } from "../../../util/iife"
|
import { iife } from "../../../util/iife"
|
||||||
import { effectCmd, fail } from "../../effect-cmd"
|
import { effectCmd, fail } from "../../effect-cmd"
|
||||||
import { InstanceRef } from "@/effect/instance-ref"
|
import { InstanceRef } from "@/effect/instance-ref"
|
||||||
import type { InstanceContext } from "@/project/instance-context"
|
import type { InstanceContext } from "@/project/instance"
|
||||||
|
|
||||||
export const AgentCommand = effectCmd({
|
export const AgentCommand = effectCmd({
|
||||||
command: "agent <name>",
|
command: "agent <name>",
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
import { Effect, Layer, Option } from "effect"
|
import { Effect, Layer, Option } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
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 { PluginBoot } from "@opencode-ai/core/plugin/boot"
|
||||||
import { effectCmd } from "../../effect-cmd"
|
import { effectCmd } from "../../effect-cmd"
|
||||||
|
|
||||||
const Runtime = Layer.mergeAll(LocationServiceMap.layer)
|
const Runtime = Layer.mergeAll(InstanceServiceMap.layer)
|
||||||
|
|
||||||
export const V2Command = effectCmd({
|
export const V2Command = effectCmd({
|
||||||
command: "v2",
|
command: "v2",
|
||||||
@@ -37,7 +37,7 @@ export const V2Command = effectCmd({
|
|||||||
process.stdout.write(JSON.stringify(result, null, 2) + EOL)
|
process.stdout.write(JSON.stringify(result, null, 2) + EOL)
|
||||||
},
|
},
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
LocationServiceMap.get({
|
InstanceServiceMap.get({
|
||||||
directory: process.cwd(),
|
directory: process.cwd(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,20 +1,11 @@
|
|||||||
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
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) {
|
function displayOffsetIndex(value: string, offset: number) {
|
||||||
if (offset <= 0) return 0
|
if (offset <= 0) return 0
|
||||||
|
|
||||||
let width = 0
|
let width = 0
|
||||||
for (const part of graphemes.segment(value)) {
|
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
|
if (next > offset) return part.index
|
||||||
width = next
|
width = next
|
||||||
}
|
}
|
||||||
@@ -22,20 +13,20 @@ function displayOffsetIndex(value: string, offset: number) {
|
|||||||
return value.length
|
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))
|
return value.slice(displayOffsetIndex(value, start), displayOffsetIndex(value, end))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function displayCharAt(value: string, offset: number) {
|
export function displayCharAt(value: string, offset: number) {
|
||||||
let width = 0
|
let width = 0
|
||||||
for (const part of graphemes.segment(value)) {
|
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
|
if (offset === width || offset < next) return part.segment
|
||||||
width = next
|
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 text = displaySlice(value, 0, offset)
|
||||||
const index = text.lastIndexOf("@")
|
const index = text.lastIndexOf("@")
|
||||||
if (index === -1) return
|
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 before = index === 0 ? undefined : text[index - 1]
|
||||||
const query = text.slice(index)
|
const query = text.slice(index)
|
||||||
if ((before === undefined || /\s/.test(before)) && !/\s/.test(query)) {
|
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 { Effect } from "effect"
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { effectCmd } from "../effect-cmd"
|
import { effectCmd } from "../effect-cmd"
|
||||||
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { ServerAuth } from "@/server/auth"
|
import { ServerAuth } from "@/server/auth"
|
||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
|
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
|
||||||
import { Agent } from "@/agent/agent"
|
import { Agent } from "@/agent/agent"
|
||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
|
||||||
import { FormatError, FormatUnknownError } from "../error"
|
import { FormatError, FormatUnknownError } from "../error"
|
||||||
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin"
|
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin"
|
||||||
|
|
||||||
@@ -235,7 +235,6 @@ export const RunCommand = effectCmd({
|
|||||||
}),
|
}),
|
||||||
handler: Effect.fn("Cli.run")(function* (args) {
|
handler: Effect.fn("Cli.run")(function* (args) {
|
||||||
const agentSvc = yield* Agent.Service
|
const agentSvc = yield* Agent.Service
|
||||||
const flags = yield* RuntimeFlags.Service
|
|
||||||
yield* Effect.promise(async () => {
|
yield* Effect.promise(async () => {
|
||||||
const rawMessage = [...args.message, ...(args["--"] || [])].join(" ")
|
const rawMessage = [...args.message, ...(args["--"] || [])].join(" ")
|
||||||
const thinking = args.interactive ? (args.thinking ?? true) : (args.thinking ?? false)
|
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) {
|
async function share(sdk: OpencodeClient, sessionID: string) {
|
||||||
const cfg = await sdk.config.get()
|
const cfg = await sdk.config.get()
|
||||||
if (!cfg.data) return
|
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) => {
|
const res = await sdk.session.share({ sessionID }).catch((error) => {
|
||||||
if (error instanceof Error && error.message.includes("disabled")) {
|
if (error instanceof Error && error.message.includes("disabled")) {
|
||||||
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
|
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ const appBindingCommands = [
|
|||||||
"command.palette.show",
|
"command.palette.show",
|
||||||
"session.list",
|
"session.list",
|
||||||
"session.new",
|
"session.new",
|
||||||
|
"session.cycle_recent",
|
||||||
|
"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",
|
||||||
@@ -479,15 +481,37 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...Array.from({ length: 9 }, (_, i) => ({
|
...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
|
||||||
name: `session.quick_switch.${i + 1}`,
|
? [
|
||||||
title: `Switch to session in quick slot ${i + 1}`,
|
{
|
||||||
category: "Session",
|
name: "session.cycle_recent",
|
||||||
hidden: true,
|
title: "Cycle to previous recent session",
|
||||||
run: () => {
|
category: "Session",
|
||||||
local.session.quickSwitch(i + 1)
|
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",
|
name: "model.list",
|
||||||
title: "Switch model",
|
title: "Switch model",
|
||||||
@@ -802,7 +826,14 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
|
|
||||||
useBindings(() => ({
|
useBindings(() => ({
|
||||||
enabled: command.matcher,
|
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(() => ({
|
useBindings(() => ({
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ export function DialogSessionList() {
|
|||||||
const [toDelete, setToDelete] = createSignal<string>()
|
const [toDelete, setToDelete] = createSignal<string>()
|
||||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||||
const deleteHint = useCommandShortcut("session.delete")
|
const deleteHint = useCommandShortcut("session.delete")
|
||||||
const quickSwitch1 = useCommandShortcut("session.quick_switch.1")
|
|
||||||
const quickSwitch9 = useCommandShortcut("session.quick_switch.9")
|
|
||||||
|
|
||||||
const [searchResults, { refetch }] = createResource(
|
const [searchResults, { refetch }] = createResource(
|
||||||
() => ({ query: search(), filter: sync.session.query() }),
|
() => ({ query: search(), filter: sync.session.query() }),
|
||||||
@@ -132,18 +130,10 @@ export function DialogSessionList() {
|
|||||||
|
|
||||||
const [browseOrder] = createSignal<string[]>(orderByRecency(sync.data.session))
|
const [browseOrder] = createSignal<string[]>(orderByRecency(sync.data.session))
|
||||||
|
|
||||||
const quickSwitchHint = createMemo(() => {
|
const RECENT_LIMIT = 5
|
||||||
const first = quickSwitch1()
|
|
||||||
const last = quickSwitch9()
|
|
||||||
if (!first || !last) return undefined
|
|
||||||
return quickSwitchRange(first, last)
|
|
||||||
})
|
|
||||||
const quickSwitchFooterHints = createMemo(() => {
|
|
||||||
const hint = quickSwitchHint()
|
|
||||||
return hint && local.session.slots().length > 0 ? [{ title: "switch", label: hint }] : []
|
|
||||||
})
|
|
||||||
|
|
||||||
const options = createMemo(() => {
|
const options = createMemo(() => {
|
||||||
|
const enabled = Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
|
||||||
const today = new Date().toDateString()
|
const today = new Date().toDateString()
|
||||||
const sessionMap = new Map(
|
const sessionMap = new Map(
|
||||||
sessions()
|
sessions()
|
||||||
@@ -154,9 +144,17 @@ export function DialogSessionList() {
|
|||||||
const searchResult = searchResults()
|
const searchResult = searchResults()
|
||||||
const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder()
|
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 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) {
|
function buildOption(id: string, category: string) {
|
||||||
const x = sessionMap.get(id)
|
const x = sessionMap.get(id)
|
||||||
@@ -200,7 +198,7 @@ export function DialogSessionList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const remaining = displayOrder
|
const remaining = displayOrder
|
||||||
.filter((id) => !pinnedSet.has(id))
|
.filter((id) => !pinnedSet.has(id) && !recentSet.has(id))
|
||||||
.map((id) => {
|
.map((id) => {
|
||||||
const x = sessionMap.get(id)
|
const x = sessionMap.get(id)
|
||||||
if (!x) return undefined
|
if (!x) return undefined
|
||||||
@@ -209,7 +207,11 @@ export function DialogSessionList() {
|
|||||||
})
|
})
|
||||||
.filter((x) => x !== undefined)
|
.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(() => {
|
onMount(() => {
|
||||||
@@ -234,13 +236,32 @@ export function DialogSessionList() {
|
|||||||
dialog.clear()
|
dialog.clear()
|
||||||
}}
|
}}
|
||||||
actions={[
|
actions={[
|
||||||
{
|
...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
|
||||||
command: "session.pin.toggle",
|
? [
|
||||||
title: "pin/unpin",
|
{
|
||||||
onTrigger: (option: { value: string }) => {
|
command: "session.pin.toggle",
|
||||||
local.session.togglePin(option.value)
|
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",
|
command: "session.delete",
|
||||||
title: "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_child_cycle_reverse: keybind("left", "Go to previous child session"),
|
||||||
session_parent: keybind("up", "Go to parent session"),
|
session_parent: keybind("up", "Go to parent session"),
|
||||||
session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"),
|
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_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_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"),
|
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_child_cycle_reverse: "session.child.previous",
|
||||||
session_parent: "session.parent",
|
session_parent: "session.parent",
|
||||||
session_pin_toggle: "session.pin.toggle",
|
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_1: "session.quick_switch.1",
|
||||||
session_quick_switch_2: "session.quick_switch.2",
|
session_quick_switch_2: "session.quick_switch.2",
|
||||||
session_quick_switch_3: "session.quick_switch.3",
|
session_quick_switch_3: "session.quick_switch.3",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createSimpleContext } from "./helper"
|
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 { useSync } from "@tui/context/sync"
|
||||||
import { useTheme } from "@tui/context/theme"
|
import { useTheme } from "@tui/context/theme"
|
||||||
import { useRoute } from "@tui/context/route"
|
import { useRoute } from "@tui/context/route"
|
||||||
@@ -8,6 +8,7 @@ import { useEvent } from "@tui/context/event"
|
|||||||
import { uniqueBy } from "remeda"
|
import { uniqueBy } from "remeda"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
import { useToast } from "../ui/toast"
|
import { useToast } from "../ui/toast"
|
||||||
import { useArgs } from "./args"
|
import { useArgs } from "./args"
|
||||||
@@ -386,9 +387,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const [sessionStore, setSessionStore] = createStore<{
|
const [sessionStore, setSessionStore] = createStore<{
|
||||||
ready: boolean
|
ready: boolean
|
||||||
pinned: string[]
|
pinned: string[]
|
||||||
|
dismissedRecent: string[]
|
||||||
|
recentOrder: string[]
|
||||||
}>({
|
}>({
|
||||||
ready: false,
|
ready: false,
|
||||||
pinned: [],
|
pinned: [],
|
||||||
|
dismissedRecent: [],
|
||||||
|
recentOrder: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
const filePath = path.join(Global.Path.state, "session.json")
|
const filePath = path.join(Global.Path.state, "session.json")
|
||||||
@@ -404,12 +409,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
state.pending = false
|
state.pending = false
|
||||||
void Filesystem.writeJson(filePath, {
|
void Filesystem.writeJson(filePath, {
|
||||||
pinned: sessionStore.pinned,
|
pinned: sessionStore.pinned,
|
||||||
|
dismissedRecent: sessionStore.dismissedRecent,
|
||||||
|
recentOrder: sessionStore.recentOrder,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
Filesystem.readJson(filePath)
|
Filesystem.readJson(filePath)
|
||||||
.then((x: any) => {
|
.then((x: any) => {
|
||||||
if (Array.isArray(x.pinned)) setSessionStore("pinned", x.pinned)
|
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(() => {})
|
.catch(() => {})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -419,10 +428,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const event = useEvent()
|
const event = useEvent()
|
||||||
|
let cycling = false
|
||||||
|
|
||||||
const slots = createMemo(() => {
|
const slots = createMemo(() => {
|
||||||
const existing = new Set(sync.data.session.filter((x) => x.parentID === undefined).map((x) => x.id))
|
const rootSessions = sync.data.session.filter((x) => x.parentID === undefined)
|
||||||
return sessionStore.pinned.filter((id) => existing.has(id)).slice(0, 9)
|
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) {
|
function prune(sessionID: string) {
|
||||||
@@ -433,6 +451,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
sessionStore.pinned.filter((x) => x !== sessionID),
|
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()
|
save()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -441,6 +471,25 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
prune(evt.properties.info.id)
|
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 {
|
return {
|
||||||
get ready() {
|
get ready() {
|
||||||
return sessionStore.ready
|
return sessionStore.ready
|
||||||
@@ -448,10 +497,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
pinned() {
|
pinned() {
|
||||||
return sessionStore.pinned
|
return sessionStore.pinned
|
||||||
},
|
},
|
||||||
|
dismissedRecent() {
|
||||||
|
return sessionStore.dismissedRecent
|
||||||
|
},
|
||||||
|
recentOrder() {
|
||||||
|
return sessionStore.recentOrder
|
||||||
|
},
|
||||||
slots,
|
slots,
|
||||||
isPinned(sessionID: string) {
|
isPinned(sessionID: string) {
|
||||||
return sessionStore.pinned.includes(sessionID)
|
return sessionStore.pinned.includes(sessionID)
|
||||||
},
|
},
|
||||||
|
isDismissed(sessionID: string) {
|
||||||
|
return sessionStore.dismissedRecent.includes(sessionID)
|
||||||
|
},
|
||||||
togglePin(sessionID: string) {
|
togglePin(sessionID: string) {
|
||||||
batch(() => {
|
batch(() => {
|
||||||
const exists = sessionStore.pinned.includes(sessionID)
|
const exists = sessionStore.pinned.includes(sessionID)
|
||||||
@@ -462,12 +520,52 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
save()
|
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) {
|
quickSwitch(slot: number) {
|
||||||
const target = slots()[slot - 1]
|
const target = slots()[slot - 1]
|
||||||
if (!target) return
|
if (!target) return
|
||||||
if (route.data.type === "session" && route.data.sessionID === target) return
|
if (route.data.type === "session" && route.data.sessionID === target) return
|
||||||
route.navigate({ type: "session", sessionID: target })
|
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 type { TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||||
import { createMemo, For, type Accessor } from "solid-js"
|
import { createMemo, For, type Accessor } from "solid-js"
|
||||||
import { DEFAULT_THEMES, useTheme } from "@tui/context/theme"
|
import { DEFAULT_THEMES, useTheme } from "@tui/context/theme"
|
||||||
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { useCommandShortcut } from "../../keymap"
|
import { useCommandShortcut } from "../../keymap"
|
||||||
|
|
||||||
const themeCount = Object.keys(DEFAULT_THEMES).length
|
const themeCount = Object.keys(DEFAULT_THEMES).length
|
||||||
@@ -28,6 +29,8 @@ type Shortcuts = {
|
|||||||
messagesToggleConceal: TipShortcut
|
messagesToggleConceal: TipShortcut
|
||||||
modelCycleRecent: TipShortcut
|
modelCycleRecent: TipShortcut
|
||||||
modelList: TipShortcut
|
modelList: TipShortcut
|
||||||
|
sessionCycleRecent: TipShortcut
|
||||||
|
sessionCycleRecentReverse: TipShortcut
|
||||||
sessionExport: TipShortcut
|
sessionExport: TipShortcut
|
||||||
sessionInterrupt: TipShortcut
|
sessionInterrupt: TipShortcut
|
||||||
sessionList: TipShortcut
|
sessionList: TipShortcut
|
||||||
@@ -38,6 +41,7 @@ type Shortcuts = {
|
|||||||
sessionQuickSwitch9: TipShortcut
|
sessionQuickSwitch9: TipShortcut
|
||||||
sessionSidebarToggle: TipShortcut
|
sessionSidebarToggle: TipShortcut
|
||||||
sessionTimeline: TipShortcut
|
sessionTimeline: TipShortcut
|
||||||
|
sessionToggleRecent: TipShortcut
|
||||||
statusView: TipShortcut
|
statusView: TipShortcut
|
||||||
terminalSuspend: TipShortcut
|
terminalSuspend: TipShortcut
|
||||||
themeList: 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_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) {
|
function shortcutText(value: string) {
|
||||||
return `{highlight}${value}{/highlight}`
|
return `{highlight}${value}{/highlight}`
|
||||||
@@ -118,6 +121,8 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
|
|||||||
messagesToggleConceal: configShortcut(props.api, "session.toggle.conceal"),
|
messagesToggleConceal: configShortcut(props.api, "session.toggle.conceal"),
|
||||||
modelCycleRecent: useCommandShortcut("model.cycle_recent"),
|
modelCycleRecent: useCommandShortcut("model.cycle_recent"),
|
||||||
modelList: useCommandShortcut("model.list"),
|
modelList: useCommandShortcut("model.list"),
|
||||||
|
sessionCycleRecent: useCommandShortcut("session.cycle_recent"),
|
||||||
|
sessionCycleRecentReverse: useCommandShortcut("session.cycle_recent_reverse"),
|
||||||
sessionExport: configShortcut(props.api, "session.export"),
|
sessionExport: configShortcut(props.api, "session.export"),
|
||||||
sessionInterrupt: configShortcut(props.api, "session.interrupt"),
|
sessionInterrupt: configShortcut(props.api, "session.interrupt"),
|
||||||
sessionList: useCommandShortcut("session.list"),
|
sessionList: useCommandShortcut("session.list"),
|
||||||
@@ -128,6 +133,7 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
|
|||||||
sessionQuickSwitch9: useCommandShortcut("session.quick_switch.9"),
|
sessionQuickSwitch9: useCommandShortcut("session.quick_switch.9"),
|
||||||
sessionSidebarToggle: configShortcut(props.api, "session.sidebar.toggle"),
|
sessionSidebarToggle: configShortcut(props.api, "session.sidebar.toggle"),
|
||||||
sessionTimeline: configShortcut(props.api, "session.timeline"),
|
sessionTimeline: configShortcut(props.api, "session.timeline"),
|
||||||
|
sessionToggleRecent: configShortcut(props.api, "session.toggle.recent"),
|
||||||
statusView: useCommandShortcut("opencode.status"),
|
statusView: useCommandShortcut("opencode.status"),
|
||||||
terminalSuspend: useCommandShortcut("terminal.suspend"),
|
terminalSuspend: useCommandShortcut("terminal.suspend"),
|
||||||
themeList: useCommandShortcut("theme.switch"),
|
themeList: useCommandShortcut("theme.switch"),
|
||||||
@@ -139,13 +145,8 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
|
|||||||
return value ? [value] : []
|
return value ? [value] : []
|
||||||
})
|
})
|
||||||
return tips[Math.floor(tipOffset * tips.length)] ?? NO_MODELS_TIP
|
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(() => parse(tip()))
|
||||||
const parts = createMemo(() => {
|
|
||||||
const value = tip()
|
|
||||||
if (typeof value === "string") return parse(value)
|
|
||||||
return NO_MODELS_PARTS
|
|
||||||
}, NO_MODELS_PARTS)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" maxWidth="100%">
|
<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("/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("/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("/new", shortcuts.sessionNew())} to start a fresh conversation session`,
|
||||||
(shortcuts) => `Use ${commandText("/sessions", shortcuts.sessionList())} to list, pin, and continue sessions`,
|
(shortcuts) => `Use ${commandText("/sessions", shortcuts.sessionList())} to list and continue previous conversations`,
|
||||||
(shortcuts) => press(shortcuts.sessionPinToggle(), "in the session list to pin a session so it stays at the top"),
|
...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
|
||||||
(shortcuts) =>
|
? ([
|
||||||
shortcuts.sessionQuickSwitch1() && shortcuts.sessionQuickSwitch9()
|
(shortcuts) =>
|
||||||
? `Pinned sessions are assigned quick slots; use ${shortcutText(shortcuts.sessionQuickSwitch1())} through ${shortcutText(shortcuts.sessionQuickSwitch9())} to switch`
|
press(shortcuts.sessionPinToggle(), "in the session list to pin a session so it stays at the top"),
|
||||||
: undefined,
|
(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",
|
"Run {highlight}/compact{/highlight} to summarize long sessions near context limits",
|
||||||
(shortcuts) => `Use ${commandText("/export", shortcuts.sessionExport())} to save the conversation as Markdown`,
|
(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"),
|
(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 { Spinner } from "@tui/component/spinner"
|
||||||
import { useTheme } from "@tui/context/theme"
|
import { useTheme } from "@tui/context/theme"
|
||||||
import { useLocal } from "@tui/context/local"
|
import { useLocal } from "@tui/context/local"
|
||||||
import { reasoningTitle, useThinkingMode } from "@tui/context/thinking"
|
|
||||||
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||||
import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
|
import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
|
||||||
import { useBindings } from "../../keymap"
|
import { useBindings } from "../../keymap"
|
||||||
@@ -318,11 +317,7 @@ function AssistantMessage(props: {
|
|||||||
<AssistantText part={part as SessionMessageAssistantText} syntax={props.syntax} />
|
<AssistantText part={part as SessionMessageAssistantText} syntax={props.syntax} />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={part.type === "reasoning"}>
|
<Match when={part.type === "reasoning"}>
|
||||||
<AssistantReasoning
|
<AssistantReasoning part={part as SessionMessageAssistantReasoning} subtleSyntax={props.subtleSyntax} />
|
||||||
part={part as SessionMessageAssistantReasoning}
|
|
||||||
subtleSyntax={props.subtleSyntax}
|
|
||||||
completedAt={() => props.message.time.completed}
|
|
||||||
/>
|
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={part.type === "tool"}>
|
<Match when={part.type === "tool"}>
|
||||||
<AssistantTool part={part as SessionMessageAssistantTool} sessionID={props.sessionID} />
|
<AssistantTool part={part as SessionMessageAssistantTool} sessionID={props.sessionID} />
|
||||||
@@ -383,64 +378,30 @@ function AssistantText(props: { part: SessionMessageAssistantText; syntax: Synta
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AssistantReasoning(props: {
|
function AssistantReasoning(props: { part: SessionMessageAssistantReasoning; subtleSyntax: SyntaxStyle }) {
|
||||||
part: SessionMessageAssistantReasoning
|
|
||||||
subtleSyntax: SyntaxStyle
|
|
||||||
completedAt: () => number | undefined
|
|
||||||
}) {
|
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const thinking = useThinkingMode()
|
|
||||||
const [expanded, setExpanded] = createSignal(false)
|
|
||||||
const content = createMemo(() => props.part.text.replace("[REDACTED]", "").trim())
|
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 (
|
return (
|
||||||
<Show when={content() && thinking.mode() !== "hide"}>
|
<Show when={content()}>
|
||||||
<Switch>
|
<box
|
||||||
<Match when={!inMinimal() || expanded()}>
|
paddingLeft={2}
|
||||||
<box
|
marginTop={1}
|
||||||
paddingLeft={2}
|
flexDirection="column"
|
||||||
marginTop={1}
|
border={["left"]}
|
||||||
flexDirection="column"
|
customBorderChars={SplitBorder.customBorderChars}
|
||||||
border={["left"]}
|
borderColor={theme.backgroundElement}
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
flexShrink={0}
|
||||||
borderColor={theme.backgroundElement}
|
>
|
||||||
flexShrink={0}
|
<code
|
||||||
onMouseUp={toggle}
|
filetype="markdown"
|
||||||
>
|
drawUnstyledText={false}
|
||||||
<code
|
streaming={true}
|
||||||
filetype="markdown"
|
syntaxStyle={props.subtleSyntax}
|
||||||
drawUnstyledText={false}
|
content={"_Thinking:_ " + content()}
|
||||||
streaming={true}
|
conceal={true}
|
||||||
syntaxStyle={props.subtleSyntax}
|
fg={theme.textMuted}
|
||||||
content={(inMinimal() ? "▼ " : "") + "_Thinking:_ " + content()}
|
/>
|
||||||
conceal={true}
|
</box>
|
||||||
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>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1067,16 +1067,14 @@ async function load(input: { api: Api; config: TuiConfig.Resolved; dispose?: ()
|
|||||||
}
|
}
|
||||||
runtime = next
|
runtime = next
|
||||||
try {
|
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 ?? [])
|
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
|
||||||
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
|
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
|
||||||
log.info("skipping external tui plugins in pure mode", { count: 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)) {
|
for (const item of internalTuiPlugins(flags)) {
|
||||||
log.info("loading internal tui plugin", { id: item.id })
|
log.info("loading internal tui plugin", { id: item.id })
|
||||||
const entry = loadInternalPlugin(item)
|
const entry = loadInternalPlugin(item)
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ import * as Model from "../../util/model"
|
|||||||
import { formatTranscript } from "../../util/transcript"
|
import { formatTranscript } from "../../util/transcript"
|
||||||
import { UI } from "@/cli/ui.ts"
|
import { UI } from "@/cli/ui.ts"
|
||||||
import { useTuiConfig } from "../../context/tui-config"
|
import { useTuiConfig } from "../../context/tui-config"
|
||||||
import { nextThinkingMode, reasoningTitle, useThinkingMode, type ThinkingMode } from "../../context/thinking"
|
|
||||||
import { getScrollAcceleration } from "../../util/scroll"
|
import { getScrollAcceleration } from "../../util/scroll"
|
||||||
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
|
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
|
||||||
import { DialogRetryAction } from "../../component/dialog-retry-action"
|
import { DialogRetryAction } from "../../component/dialog-retry-action"
|
||||||
@@ -158,7 +157,6 @@ const context = createContext<{
|
|||||||
width: number
|
width: number
|
||||||
sessionID: string
|
sessionID: string
|
||||||
conceal: () => boolean
|
conceal: () => boolean
|
||||||
thinkingMode: () => ThinkingMode
|
|
||||||
showThinking: () => boolean
|
showThinking: () => boolean
|
||||||
showTimestamps: () => boolean
|
showTimestamps: () => boolean
|
||||||
showDetails: () => boolean
|
showDetails: () => boolean
|
||||||
@@ -216,9 +214,7 @@ export function Session() {
|
|||||||
const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto")
|
const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto")
|
||||||
const [sidebarOpen, setSidebarOpen] = createSignal(false)
|
const [sidebarOpen, setSidebarOpen] = createSignal(false)
|
||||||
const [conceal, setConceal] = createSignal(true)
|
const [conceal, setConceal] = createSignal(true)
|
||||||
const thinking = useThinkingMode()
|
const [showThinking, setShowThinking] = kv.signal("thinking_visibility", true)
|
||||||
const thinkingMode = thinking.mode
|
|
||||||
const showThinking = createMemo(() => thinkingMode() !== "hide")
|
|
||||||
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
|
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
|
||||||
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
|
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
|
||||||
const [showAssistantMetadata, _setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
|
const [showAssistantMetadata, _setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
|
||||||
@@ -687,12 +683,7 @@ export function Session() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: (() => {
|
title: showThinking() ? "Hide thinking" : "Show thinking",
|
||||||
const next = nextThinkingMode(thinkingMode())
|
|
||||||
if (next === "minimal") return "Switch thinking to minimal"
|
|
||||||
if (next === "hide") return "Hide thinking"
|
|
||||||
return "Show thinking"
|
|
||||||
})(),
|
|
||||||
value: "session.toggle.thinking",
|
value: "session.toggle.thinking",
|
||||||
category: "Session",
|
category: "Session",
|
||||||
slash: {
|
slash: {
|
||||||
@@ -700,17 +691,7 @@ export function Session() {
|
|||||||
aliases: ["toggle-thinking"],
|
aliases: ["toggle-thinking"],
|
||||||
},
|
},
|
||||||
run: () => {
|
run: () => {
|
||||||
// Env override forces minimal for the process. Updating KV here would
|
setShowThinking((prev) => !prev)
|
||||||
// silently diverge from what's rendered; tell the user instead.
|
|
||||||
if (thinking.locked()) {
|
|
||||||
toast.show({
|
|
||||||
message: "Thinking mode is locked to minimal by OPENCODE_EXPERIMENTAL_MINIMAL_THINKING",
|
|
||||||
variant: "info",
|
|
||||||
})
|
|
||||||
dialog.clear()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
thinking.set(nextThinkingMode(thinkingMode()))
|
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1105,7 +1086,6 @@ export function Session() {
|
|||||||
},
|
},
|
||||||
sessionID: route.sessionID,
|
sessionID: route.sessionID,
|
||||||
conceal,
|
conceal,
|
||||||
thinkingMode,
|
|
||||||
showThinking,
|
showThinking,
|
||||||
showTimestamps,
|
showTimestamps,
|
||||||
showDetails,
|
showDetails,
|
||||||
@@ -1512,77 +1492,32 @@ const PART_MAPPING = {
|
|||||||
function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
|
function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
|
||||||
const { theme, subtleSyntax } = useTheme()
|
const { theme, subtleSyntax } = useTheme()
|
||||||
const ctx = use()
|
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(() => {
|
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()
|
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 (
|
return (
|
||||||
<Show when={content() && ctx.thinkingMode() !== "hide"}>
|
<Show when={content() && ctx.showThinking()}>
|
||||||
<Switch>
|
<box
|
||||||
<Match when={!inMinimal() || expanded()}>
|
id={"text-" + props.part.id}
|
||||||
{/* Full markdown block: `show` mode, or `minimal` after the user opens it. */}
|
paddingLeft={2}
|
||||||
<box
|
marginTop={1}
|
||||||
id={"text-" + props.part.id}
|
flexDirection="column"
|
||||||
paddingLeft={2}
|
border={["left"]}
|
||||||
marginTop={1}
|
customBorderChars={SplitBorder.customBorderChars}
|
||||||
flexDirection="column"
|
borderColor={theme.backgroundElement}
|
||||||
border={["left"]}
|
>
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
<code
|
||||||
borderColor={theme.backgroundElement}
|
filetype="markdown"
|
||||||
onMouseUp={toggle}
|
drawUnstyledText={false}
|
||||||
>
|
streaming={true}
|
||||||
<code
|
syntaxStyle={subtleSyntax()}
|
||||||
filetype="markdown"
|
content={"_Thinking:_ " + content()}
|
||||||
drawUnstyledText={false}
|
conceal={ctx.conceal()}
|
||||||
streaming={true}
|
fg={theme.textMuted}
|
||||||
syntaxStyle={subtleSyntax()}
|
/>
|
||||||
content={(inMinimal() ? "▼ " : "") + "_Thinking:_ " + content()}
|
</box>
|
||||||
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>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1593,15 +1528,14 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
|
|||||||
return (
|
return (
|
||||||
<Show when={props.part.text.trim()}>
|
<Show when={props.part.text.trim()}>
|
||||||
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
|
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
|
||||||
<markdown
|
<code
|
||||||
syntaxStyle={syntax()}
|
filetype="markdown"
|
||||||
|
drawUnstyledText={false}
|
||||||
streaming={true}
|
streaming={true}
|
||||||
internalBlockMode="top-level"
|
syntaxStyle={syntax()}
|
||||||
content={props.part.text.trim()}
|
content={props.part.text.trim()}
|
||||||
tableOptions={{ style: "grid" }}
|
|
||||||
conceal={ctx.conceal()}
|
conceal={ctx.conceal()}
|
||||||
fg={theme.markdownText}
|
fg={theme.text}
|
||||||
bg={theme.background}
|
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -2079,9 +2013,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
|||||||
|
|
||||||
const content = createMemo(() => {
|
const content = createMemo(() => {
|
||||||
if (!props.input.description) return ""
|
if (!props.input.description) return ""
|
||||||
const description =
|
let content = [`${Locale.titlecase(props.input.subagent_type ?? "General")} Task — ${props.input.description}`]
|
||||||
props.metadata.background === true ? `${props.input.description} (background)` : props.input.description
|
|
||||||
let content = [`${Locale.titlecase(props.input.subagent_type ?? "General")} Task — ${description}`]
|
|
||||||
|
|
||||||
if (isRunning() && tools().length > 0) {
|
if (isRunning() && tools().length > 0) {
|
||||||
// content[0] += ` · ${tools().length} toolcalls`
|
// content[0] += ` · ${tools().length} toolcalls`
|
||||||
@@ -2093,11 +2025,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (props.part.state.status === "completed") {
|
if (props.part.state.status === "completed") {
|
||||||
content.push(
|
content.push(`└ ${tools().length} toolcalls · ${Locale.duration(duration())}`)
|
||||||
props.metadata.background === true
|
|
||||||
? `└ ${tools().length} toolcalls`
|
|
||||||
: `└ ${tools().length} toolcalls · ${Locale.duration(duration())}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return content.join("\n")
|
return content.join("\n")
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createMemo, createSignal, For, Show } from "solid-js"
|
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||||
import { useRenderer } from "@opentui/solid"
|
|
||||||
import type { TextareaRenderable } from "@opentui/core"
|
import type { TextareaRenderable } from "@opentui/core"
|
||||||
import { selectedForeground, tint, useTheme } from "../../context/theme"
|
import { selectedForeground, tint, useTheme } from "../../context/theme"
|
||||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||||
@@ -13,7 +12,6 @@ import { useBindings } from "../../keymap"
|
|||||||
export function QuestionPrompt(props: { request: QuestionRequest }) {
|
export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const renderer = useRenderer()
|
|
||||||
const tuiConfig = useTuiConfig()
|
const tuiConfig = useTuiConfig()
|
||||||
|
|
||||||
const questions = createMemo(() => props.request.questions)
|
const questions = createMemo(() => props.request.questions)
|
||||||
@@ -304,10 +302,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
|||||||
}
|
}
|
||||||
onMouseOver={() => setTabHover(index())}
|
onMouseOver={() => setTabHover(index())}
|
||||||
onMouseOut={() => setTabHover(null)}
|
onMouseOut={() => setTabHover(null)}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => selectTab(index())}
|
||||||
if (renderer.getSelection()?.getSelectedText()) return
|
|
||||||
selectTab(index())
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<text
|
<text
|
||||||
fg={
|
fg={
|
||||||
@@ -332,10 +327,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
|||||||
}
|
}
|
||||||
onMouseOver={() => setTabHover("confirm")}
|
onMouseOver={() => setTabHover("confirm")}
|
||||||
onMouseOut={() => setTabHover(null)}
|
onMouseOut={() => setTabHover(null)}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => selectTab(questions().length)}
|
||||||
if (renderer.getSelection()?.getSelectedText()) return
|
|
||||||
selectTab(questions().length)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<text fg={confirm() ? selectedForeground(theme, theme.accent) : theme.textMuted}>Confirm</text>
|
<text fg={confirm() ? selectedForeground(theme, theme.accent) : theme.textMuted}>Confirm</text>
|
||||||
</box>
|
</box>
|
||||||
@@ -359,10 +351,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
|||||||
<box
|
<box
|
||||||
onMouseOver={() => moveTo(i())}
|
onMouseOver={() => moveTo(i())}
|
||||||
onMouseDown={() => moveTo(i())}
|
onMouseDown={() => moveTo(i())}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => selectOption()}
|
||||||
if (renderer.getSelection()?.getSelectedText()) return
|
|
||||||
selectOption()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<box flexDirection="row">
|
<box flexDirection="row">
|
||||||
<box backgroundColor={active() ? theme.backgroundElement : undefined} paddingRight={1}>
|
<box backgroundColor={active() ? theme.backgroundElement : undefined} paddingRight={1}>
|
||||||
@@ -391,10 +380,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
|||||||
<box
|
<box
|
||||||
onMouseOver={() => moveTo(options().length)}
|
onMouseOver={() => moveTo(options().length)}
|
||||||
onMouseDown={() => moveTo(options().length)}
|
onMouseDown={() => moveTo(options().length)}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => selectOption()}
|
||||||
if (renderer.getSelection()?.getSelectedText()) return
|
|
||||||
selectOption()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<box flexDirection="row">
|
<box flexDirection="row">
|
||||||
<box backgroundColor={other() ? theme.backgroundElement : undefined} paddingRight={1}>
|
<box backgroundColor={other() ? theme.backgroundElement : undefined} paddingRight={1}>
|
||||||
|
|||||||
@@ -38,11 +38,6 @@ export interface DialogSelectProps<T> {
|
|||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
onTrigger: (option: DialogSelectOption<T>) => void
|
onTrigger: (option: DialogSelectOption<T>) => void
|
||||||
}[]
|
}[]
|
||||||
footerHints?: {
|
|
||||||
title: string
|
|
||||||
label: string
|
|
||||||
side?: "left" | "right"
|
|
||||||
}[]
|
|
||||||
bindings?: readonly Binding<Renderable, KeyEvent>[]
|
bindings?: readonly Binding<Renderable, KeyEvent>[]
|
||||||
current?: T
|
current?: T
|
||||||
}
|
}
|
||||||
@@ -339,12 +334,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
}
|
}
|
||||||
props.ref?.(ref)
|
props.ref?.(ref)
|
||||||
|
|
||||||
const visibleActions = createMemo(() => [
|
const visibleActions = createMemo(() =>
|
||||||
...actions()
|
actions()
|
||||||
.map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" }))
|
.map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" }))
|
||||||
.filter((item) => !item.disabled && item.label),
|
.filter((item) => !item.disabled && item.label),
|
||||||
...(props.footerHints ?? []),
|
)
|
||||||
])
|
|
||||||
const left = createMemo(() => visibleActions().filter((item) => item.side !== "right"))
|
const left = createMemo(() => visibleActions().filter((item) => item.side !== "right"))
|
||||||
const right = 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 { Server } from "@/server/server"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||||
|
import { WithInstance } from "@/project/with-instance"
|
||||||
import { Rpc } from "@/util/rpc"
|
import { Rpc } from "@/util/rpc"
|
||||||
import { upgrade } from "@/cli/upgrade"
|
import { upgrade } from "@/cli/upgrade"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
@@ -76,8 +77,12 @@ export const rpc = {
|
|||||||
return { url: server.url.toString() }
|
return { url: server.url.toString() }
|
||||||
},
|
},
|
||||||
async checkUpgrade(input: { directory: string }) {
|
async checkUpgrade(input: { directory: string }) {
|
||||||
await InstanceRuntime.load({ directory: input.directory })
|
await WithInstance.provide({
|
||||||
await upgrade().catch(() => {})
|
directory: input.directory,
|
||||||
|
fn: async () => {
|
||||||
|
await upgrade().catch(() => {})
|
||||||
|
},
|
||||||
|
})
|
||||||
},
|
},
|
||||||
async reload() {
|
async reload() {
|
||||||
await AppRuntime.runPromise(
|
await AppRuntime.runPromise(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Effect, Schema } from "effect"
|
|||||||
import { AppRuntime, type AppServices } from "@/effect/app-runtime"
|
import { AppRuntime, type AppServices } from "@/effect/app-runtime"
|
||||||
import { InstanceStore } from "@/project/instance-store"
|
import { InstanceStore } from "@/project/instance-store"
|
||||||
import { InstanceRef } from "@/effect/instance-ref"
|
import { InstanceRef } from "@/effect/instance-ref"
|
||||||
|
import { Instance } from "@/project/instance"
|
||||||
import { cmd, type WithDoubleDash } from "./cmd/cmd"
|
import { cmd, type WithDoubleDash } from "./cmd/cmd"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,11 +83,19 @@ export const effectCmd = <Args, A>(opts: EffectCmdOpts<Args, A>) =>
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const directory = opts.directory?.(args) ?? process.cwd()
|
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(
|
const { store, ctx } = await AppRuntime.runPromise(
|
||||||
InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))),
|
InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))),
|
||||||
)
|
)
|
||||||
try {
|
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 {
|
} finally {
|
||||||
await AppRuntime.runPromise(store.dispose(ctx))
|
await AppRuntime.runPromise(store.dispose(ctx))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,9 +72,8 @@ export function FormatError(input: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ProviderInitError: { providerID: string }
|
// ProviderInitError: { providerID: string }
|
||||||
const providerInit = configData(input, "ProviderInitError")
|
if (NamedError.hasName(input, "ProviderInitError")) {
|
||||||
if (providerInit) {
|
return `Failed to initialize provider "${(input as ErrorLike).data?.providerID}". Check credentials and configuration.`
|
||||||
return `Failed to initialize provider "${stringField(providerInit, "providerID")}". Check credentials and configuration.`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfigJsonError: { path: string, message?: string }
|
// ConfigJsonError: { path: string, message?: string }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { EffectBridge } from "@/effect/bridge"
|
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 { SessionID, MessageID } from "@/session/schema"
|
||||||
import { Effect, Layer, Context, Schema } from "effect"
|
import { Effect, Layer, Context, Schema } from "effect"
|
||||||
import { Config } from "@/config/config"
|
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)",
|
description: "Maximum image height before resizing or rejecting the attachment (default: 2000)",
|
||||||
}),
|
}),
|
||||||
max_base64_bytes: Schema.optional(PositiveInt).annotate({
|
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" })
|
}).annotate({ identifier: "ImageAttachmentConfig" })
|
||||||
export type Image = Schema.Schema.Type<typeof Image>
|
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