Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a462b1c101 |
@@ -1,50 +0,0 @@
|
||||
name: close-prs
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 22 * * *" # Daily at 10:00 PM UTC
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry-run:
|
||||
description: "Log matching PRs without closing them"
|
||||
type: boolean
|
||||
default: true
|
||||
max-close:
|
||||
description: "Maximum matching PRs to close"
|
||||
type: string
|
||||
required: false
|
||||
default: "50"
|
||||
|
||||
jobs:
|
||||
close:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 240
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Close old PRs without enough positive reactions
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
max_close="${{ inputs['max-close'] }}"
|
||||
if [ -z "$max_close" ]; then
|
||||
max_close="50"
|
||||
fi
|
||||
|
||||
args=("--threshold" "2" "--age-months" "1" "--sleep-ms" "20000" "--max-close" "$max_close")
|
||||
|
||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
args+=("--execute")
|
||||
elif [ "${{ inputs['dry-run'] }}" = "false" ]; then
|
||||
args+=("--execute")
|
||||
fi
|
||||
|
||||
bun script/github/close-prs.ts "${args[@]}"
|
||||
@@ -0,0 +1,235 @@
|
||||
name: close-stale-prs
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dryRun:
|
||||
description: "Log actions without closing PRs"
|
||||
type: boolean
|
||||
default: false
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
close-stale-prs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Close inactive PRs
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const DAYS_INACTIVE = 60
|
||||
const MAX_RETRIES = 3
|
||||
|
||||
// Adaptive delay: fast for small batches, slower for large to respect
|
||||
// GitHub's 80 content-generating requests/minute limit
|
||||
const SMALL_BATCH_THRESHOLD = 10
|
||||
const SMALL_BATCH_DELAY_MS = 1000 // 1s for daily operations (≤10 PRs)
|
||||
const LARGE_BATCH_DELAY_MS = 2000 // 2s for backlog (>10 PRs) = ~30 ops/min, well under 80 limit
|
||||
|
||||
const startTime = Date.now()
|
||||
const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000)
|
||||
const { owner, repo } = context.repo
|
||||
const dryRun = context.payload.inputs?.dryRun === "true"
|
||||
|
||||
core.info(`Dry run mode: ${dryRun}`)
|
||||
core.info(`Cutoff date: ${cutoff.toISOString()}`)
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function withRetry(fn, description = 'API call') {
|
||||
let lastError
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const result = await fn()
|
||||
return result
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
const isRateLimited = error.status === 403 &&
|
||||
(error.message?.includes('rate limit') || error.message?.includes('secondary'))
|
||||
|
||||
if (!isRateLimited) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Parse retry-after header, default to 60 seconds
|
||||
const retryAfter = error.response?.headers?.['retry-after']
|
||||
? parseInt(error.response.headers['retry-after'])
|
||||
: 60
|
||||
|
||||
// Exponential backoff: retryAfter * 2^attempt
|
||||
const backoffMs = retryAfter * 1000 * Math.pow(2, attempt)
|
||||
|
||||
core.warning(`${description}: Rate limited (attempt ${attempt + 1}/${MAX_RETRIES}). Waiting ${backoffMs / 1000}s before retry...`)
|
||||
|
||||
await sleep(backoffMs)
|
||||
}
|
||||
}
|
||||
core.error(`${description}: Max retries (${MAX_RETRIES}) exceeded`)
|
||||
throw lastError
|
||||
}
|
||||
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!, $cursor: String) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequests(first: 100, states: OPEN, after: $cursor) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
nodes {
|
||||
number
|
||||
title
|
||||
author {
|
||||
login
|
||||
}
|
||||
createdAt
|
||||
commits(last: 1) {
|
||||
nodes {
|
||||
commit {
|
||||
committedDate
|
||||
}
|
||||
}
|
||||
}
|
||||
comments(last: 1) {
|
||||
nodes {
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
reviews(last: 1) {
|
||||
nodes {
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const allPrs = []
|
||||
let cursor = null
|
||||
let hasNextPage = true
|
||||
let pageCount = 0
|
||||
|
||||
while (hasNextPage) {
|
||||
pageCount++
|
||||
core.info(`Fetching page ${pageCount} of open PRs...`)
|
||||
|
||||
const result = await withRetry(
|
||||
() => github.graphql(query, { owner, repo, cursor }),
|
||||
`GraphQL page ${pageCount}`
|
||||
)
|
||||
|
||||
allPrs.push(...result.repository.pullRequests.nodes)
|
||||
hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage
|
||||
cursor = result.repository.pullRequests.pageInfo.endCursor
|
||||
|
||||
core.info(`Page ${pageCount}: fetched ${result.repository.pullRequests.nodes.length} PRs (total: ${allPrs.length})`)
|
||||
|
||||
// Delay between pagination requests (use small batch delay for reads)
|
||||
if (hasNextPage) {
|
||||
await sleep(SMALL_BATCH_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
core.info(`Found ${allPrs.length} open pull requests`)
|
||||
|
||||
const stalePrs = allPrs.filter((pr) => {
|
||||
const dates = [
|
||||
new Date(pr.createdAt),
|
||||
pr.commits.nodes[0] ? new Date(pr.commits.nodes[0].commit.committedDate) : null,
|
||||
pr.comments.nodes[0] ? new Date(pr.comments.nodes[0].createdAt) : null,
|
||||
pr.reviews.nodes[0] ? new Date(pr.reviews.nodes[0].createdAt) : null,
|
||||
].filter((d) => d !== null)
|
||||
|
||||
const lastActivity = dates.sort((a, b) => b.getTime() - a.getTime())[0]
|
||||
|
||||
if (!lastActivity || lastActivity > cutoff) {
|
||||
core.info(`PR #${pr.number} is fresh (last activity: ${lastActivity?.toISOString() || "unknown"})`)
|
||||
return false
|
||||
}
|
||||
|
||||
core.info(`PR #${pr.number} is STALE (last activity: ${lastActivity.toISOString()})`)
|
||||
return true
|
||||
})
|
||||
|
||||
if (!stalePrs.length) {
|
||||
core.info("No stale pull requests found.")
|
||||
return
|
||||
}
|
||||
|
||||
core.info(`Found ${stalePrs.length} stale pull requests`)
|
||||
|
||||
// ============================================
|
||||
// Close stale PRs
|
||||
// ============================================
|
||||
const requestDelayMs = stalePrs.length > SMALL_BATCH_THRESHOLD
|
||||
? LARGE_BATCH_DELAY_MS
|
||||
: SMALL_BATCH_DELAY_MS
|
||||
|
||||
core.info(`Using ${requestDelayMs}ms delay between operations (${stalePrs.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`)
|
||||
|
||||
let closedCount = 0
|
||||
let skippedCount = 0
|
||||
|
||||
for (const pr of stalePrs) {
|
||||
const issue_number = pr.number
|
||||
const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.`
|
||||
|
||||
if (dryRun) {
|
||||
core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
// Add comment
|
||||
await withRetry(
|
||||
() => github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body: closeComment,
|
||||
}),
|
||||
`Comment on PR #${issue_number}`
|
||||
)
|
||||
|
||||
// Close PR
|
||||
await withRetry(
|
||||
() => github.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: issue_number,
|
||||
state: "closed",
|
||||
}),
|
||||
`Close PR #${issue_number}`
|
||||
)
|
||||
|
||||
closedCount++
|
||||
core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
|
||||
|
||||
// Delay before processing next PR
|
||||
await sleep(requestDelayMs)
|
||||
} catch (error) {
|
||||
skippedCount++
|
||||
core.error(`Failed to close PR #${issue_number}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed = Math.round((Date.now() - startTime) / 1000)
|
||||
core.info(`\n========== Summary ==========`)
|
||||
core.info(`Total open PRs found: ${allPrs.length}`)
|
||||
core.info(`Stale PRs identified: ${stalePrs.length}`)
|
||||
core.info(`PRs closed: ${closedCount}`)
|
||||
core.info(`PRs skipped (errors): ${skippedCount}`)
|
||||
core.info(`Elapsed time: ${elapsed}s`)
|
||||
core.info(`=============================`)
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -84,7 +84,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -119,7 +119,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -146,7 +146,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -168,7 +168,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -192,7 +192,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.51",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -253,7 +253,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -307,7 +307,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -337,7 +337,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -353,7 +353,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -366,7 +366,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
@@ -384,7 +384,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.51",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -520,7 +520,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -558,7 +558,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -573,7 +573,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -608,7 +608,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -657,7 +657,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
|
||||
+26
-10
@@ -70,10 +70,11 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => {
|
||||
}).json
|
||||
}
|
||||
|
||||
const providerHttpErrorsQuery = () => {
|
||||
const providerHttpErrorsQuery = (product: "go" | "zen") => {
|
||||
const filters = [
|
||||
{ column: "provider", op: "exists" },
|
||||
{ column: "user_agent", op: "contains", value: "opencode" },
|
||||
{ column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" },
|
||||
]
|
||||
const successHttpStatus = calculatedField({
|
||||
name: "is_success_http_status",
|
||||
@@ -100,15 +101,11 @@ const providerHttpErrorsQuery = () => {
|
||||
name: "FAILED",
|
||||
column: failedProviderHttpStatus.name,
|
||||
filterCombination: "AND",
|
||||
filters: [
|
||||
...filters,
|
||||
{ column: "event_type", op: "=", value: "llm.error" },
|
||||
{ column: "llm.error.code", op: "!=", value: "404" },
|
||||
],
|
||||
filters: [...filters, { column: "event_type", op: "=", value: "llm.error" }],
|
||||
},
|
||||
],
|
||||
formulas: [
|
||||
{ name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 200), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" },
|
||||
{ name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 50), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" },
|
||||
],
|
||||
timeRange: 900,
|
||||
}).json
|
||||
@@ -218,10 +215,29 @@ new honeycomb.Trigger("LowModelTpsZen", {
|
||||
],
|
||||
})
|
||||
|
||||
new honeycomb.Trigger("IncreasedProviderHttpErrors", {
|
||||
name: "Increased Provider HTTP Errors",
|
||||
new honeycomb.Trigger("IncreasedProviderHttpErrorsGo", {
|
||||
name: "Increased Provider HTTP Errors [Go]",
|
||||
description,
|
||||
queryJson: providerHttpErrorsQuery(),
|
||||
queryJson: providerHttpErrorsQuery("go"),
|
||||
alertType: "on_change",
|
||||
frequency: 300,
|
||||
thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }],
|
||||
recipients: [
|
||||
{
|
||||
id: webhookRecipient.id,
|
||||
notificationDetails: [
|
||||
{
|
||||
variables: [{ name: "type", value: "provider_http_errors" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
new honeycomb.Trigger("IncreasedProviderHttpErrorsZen", {
|
||||
name: "Increased Provider HTTP Errors [Zen]",
|
||||
description,
|
||||
queryJson: providerHttpErrorsQuery("zen"),
|
||||
alertType: "on_change",
|
||||
frequency: 300,
|
||||
thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.51",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -125,7 +125,6 @@ export function applyDirectoryEvent(input: {
|
||||
const info = (event.properties as { info: Session }).info
|
||||
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
||||
if (info.time.archived) {
|
||||
if (input.store.session[result.index]!.time.archived === info.time.archived) break
|
||||
if (result.found) {
|
||||
input.setStore(
|
||||
"session",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.51",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.51",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.51",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.51",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -5,8 +5,7 @@ import { produce, type Draft } from "immer"
|
||||
import { ModelV2 } from "./model"
|
||||
import { PluginV2 } from "./plugin"
|
||||
import { ProviderV2 } from "./provider"
|
||||
import { Location } from "./location"
|
||||
import { EventV2 } from "./event"
|
||||
import { Instance } from "./instance"
|
||||
|
||||
type ProviderRecord = {
|
||||
provider: ProviderV2.Info
|
||||
@@ -25,15 +24,6 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundErr
|
||||
modelID: ModelV2.ID,
|
||||
}) {}
|
||||
|
||||
export const Event = {
|
||||
ModelUpdated: EventV2.define({
|
||||
type: "catalog.model.updated",
|
||||
schema: {
|
||||
model: ModelV2.Info,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly provider: {
|
||||
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info, ProviderNotFoundError>
|
||||
@@ -67,11 +57,10 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
yield* Location.Service
|
||||
yield* Instance.Service
|
||||
let records = HashMap.empty<ProviderV2.ID, ProviderRecord>()
|
||||
let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined
|
||||
const plugin = yield* PluginV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
|
||||
const resolve = (model: ModelV2.Info) => {
|
||||
const provider = Option.getOrThrow(HashMap.get(records, model.providerID)).provider
|
||||
@@ -168,12 +157,14 @@ export const layer = Layer.effect(
|
||||
)
|
||||
const updated = yield* plugin.trigger("model.update", {}, { model, cancel: false })
|
||||
if (updated.cancel) return
|
||||
const next = new ModelV2.Info({ ...updated.model, id: modelID, providerID })
|
||||
records = HashMap.set(records, providerID, {
|
||||
provider: record.provider,
|
||||
models: HashMap.set(record.models, modelID, next),
|
||||
models: HashMap.set(
|
||||
record.models,
|
||||
modelID,
|
||||
new ModelV2.Info({ ...updated.model, id: modelID, providerID }),
|
||||
),
|
||||
})
|
||||
yield* events.publish(Event.ModelUpdated, { model: resolve(next) })
|
||||
return
|
||||
}),
|
||||
|
||||
@@ -266,4 +257,4 @@ export const layer = Layer.effect(
|
||||
|
||||
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide(PluginV2.defaultLayer))
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import { Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { Location } from "./location"
|
||||
import { withStatics } from "./schema"
|
||||
import { Identifier } from "./util/identifier"
|
||||
|
||||
export const ID = Schema.String.pipe(
|
||||
Schema.brand("Event.ID"),
|
||||
withStatics((schema) => ({ create: () => schema.make("evt_" + Identifier.ascending()) })),
|
||||
)
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export type Definition<Type extends string = string, DataSchema extends Schema.Top = Schema.Top> = {
|
||||
readonly type: Type
|
||||
readonly version?: number
|
||||
readonly aggregate?: string
|
||||
readonly data: DataSchema
|
||||
}
|
||||
|
||||
export type Data<D extends Definition> = Schema.Schema.Type<D["data"]>
|
||||
|
||||
export type Payload<D extends Definition = Definition> = {
|
||||
readonly id: ID
|
||||
readonly type: D["type"]
|
||||
readonly data: Data<D>
|
||||
readonly version?: number
|
||||
readonly location?: Location.Ref
|
||||
readonly metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type Sync = (event: Payload) => Effect.Effect<void>
|
||||
|
||||
export const registry = new Map<string, Definition>()
|
||||
|
||||
export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
|
||||
readonly type: Type
|
||||
readonly version?: number
|
||||
readonly aggregate?: string
|
||||
readonly schema: Fields
|
||||
}): Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> & Definition<Type, Schema.Struct<Fields>> {
|
||||
const Data = Schema.Struct(input.schema)
|
||||
const Payload = Schema.Struct({
|
||||
id: ID,
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
type: Schema.Literal(input.type),
|
||||
version: Schema.optional(Schema.Number),
|
||||
location: Schema.optional(Location.Ref),
|
||||
data: Data,
|
||||
}).annotate({ identifier: input.type })
|
||||
|
||||
const definition = Object.assign(Payload, {
|
||||
type: input.type,
|
||||
...(input.version === undefined ? {} : { version: input.version }),
|
||||
...(input.aggregate === undefined ? {} : { aggregate: input.aggregate }),
|
||||
data: Data,
|
||||
})
|
||||
registry.set(input.type, definition)
|
||||
return definition as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> &
|
||||
Definition<Type, Schema.Struct<Fields>>
|
||||
}
|
||||
|
||||
export function definitions() {
|
||||
return registry.values().toArray()
|
||||
}
|
||||
|
||||
export interface PublishOptions {
|
||||
readonly id?: ID
|
||||
readonly metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type Unsubscribe = Effect.Effect<void>
|
||||
|
||||
export interface Interface {
|
||||
readonly publish: <D extends Definition>(
|
||||
definition: D,
|
||||
data: Data<D>,
|
||||
options?: PublishOptions,
|
||||
) => Effect.Effect<Payload<D>>
|
||||
readonly publishEvent: <D extends Definition>(event: Payload<D>) => Effect.Effect<Payload<D>>
|
||||
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
|
||||
readonly all: () => Stream.Stream<Payload>
|
||||
readonly sync: (handler: Sync) => Effect.Effect<Unsubscribe>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const all = yield* PubSub.unbounded<Payload>()
|
||||
const typed = new Map<string, PubSub.PubSub<Payload>>()
|
||||
const syncHandlers = new Array<Sync>()
|
||||
|
||||
const getOrCreate = (definition: Definition) =>
|
||||
Effect.gen(function* () {
|
||||
const existing = typed.get(definition.type)
|
||||
if (existing) return existing
|
||||
const pubsub = yield* PubSub.unbounded<Payload>()
|
||||
typed.set(definition.type, pubsub)
|
||||
return pubsub
|
||||
})
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
yield* PubSub.shutdown(all)
|
||||
yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true })
|
||||
}),
|
||||
)
|
||||
|
||||
function publishEvent<D extends Definition>(event: Payload<D>) {
|
||||
return Effect.gen(function* () {
|
||||
for (const sync of syncHandlers) {
|
||||
yield* sync(event as Payload)
|
||||
}
|
||||
const pubsub = typed.get(event.type)
|
||||
if (pubsub) yield* PubSub.publish(pubsub, event as Payload)
|
||||
yield* PubSub.publish(all, event as Payload)
|
||||
return event
|
||||
})
|
||||
}
|
||||
|
||||
function publish<D extends Definition>(definition: D, data: Data<D>, options?: PublishOptions) {
|
||||
return Effect.gen(function* () {
|
||||
const location = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
|
||||
const event = {
|
||||
id: options?.id ?? ID.create(),
|
||||
...(options?.metadata ? { metadata: options.metadata } : {}),
|
||||
type: definition.type,
|
||||
...(definition.version === undefined ? {} : { version: definition.version }),
|
||||
...(location ? { location } : {}),
|
||||
data,
|
||||
} as Payload<D>
|
||||
return yield* publishEvent(event)
|
||||
})
|
||||
}
|
||||
|
||||
const subscribe = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> =>
|
||||
Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe(
|
||||
Stream.map((event) => event as Payload<D>),
|
||||
)
|
||||
|
||||
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(all)
|
||||
const sync = (handler: Sync): Effect.Effect<Unsubscribe> =>
|
||||
Effect.sync(() => {
|
||||
syncHandlers.push(handler)
|
||||
return Effect.sync(() => {
|
||||
const index = syncHandlers.indexOf(handler)
|
||||
if (index >= 0) syncHandlers.splice(index, 1)
|
||||
})
|
||||
})
|
||||
|
||||
return Service.of({ publish, publishEvent, subscribe, all: streamAll, sync })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
export * as EventV2 from "./event"
|
||||
@@ -5,7 +5,15 @@ function truthy(key: string) {
|
||||
return value === "true" || value === "1"
|
||||
}
|
||||
|
||||
function number(key: string) {
|
||||
const value = process.env[key]
|
||||
if (!value) return undefined
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
|
||||
}
|
||||
|
||||
const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
|
||||
const OPENCODE_DISABLE_CLAUDE_CODE = truthy("OPENCODE_DISABLE_CLAUDE_CODE")
|
||||
const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
|
||||
|
||||
export const Flag = {
|
||||
@@ -22,14 +30,21 @@ export const Flag = {
|
||||
OPENCODE_DISABLE_TERMINAL_TITLE: truthy("OPENCODE_DISABLE_TERMINAL_TITLE"),
|
||||
OPENCODE_SHOW_TTFD: truthy("OPENCODE_SHOW_TTFD"),
|
||||
OPENCODE_PERMISSION: process.env["OPENCODE_PERMISSION"],
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS"),
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: truthy("OPENCODE_DISABLE_LSP_DOWNLOAD"),
|
||||
OPENCODE_DISABLE_AUTOCOMPACT: truthy("OPENCODE_DISABLE_AUTOCOMPACT"),
|
||||
OPENCODE_DISABLE_MODELS_FETCH: truthy("OPENCODE_DISABLE_MODELS_FETCH"),
|
||||
OPENCODE_DISABLE_MOUSE: truthy("OPENCODE_DISABLE_MOUSE"),
|
||||
OPENCODE_DISABLE_CLAUDE_CODE,
|
||||
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
|
||||
OPENCODE_DISABLE_EXTERNAL_SKILLS: truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
|
||||
OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"],
|
||||
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
|
||||
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
|
||||
OPENCODE_ENABLE_QUESTION_TOOL: truthy("OPENCODE_ENABLE_QUESTION_TOOL"),
|
||||
|
||||
// Experimental
|
||||
OPENCODE_EXPERIMENTAL,
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_FILEWATCHER").pipe(
|
||||
Config.withDefault(false),
|
||||
),
|
||||
@@ -38,13 +53,22 @@ export const Flag = {
|
||||
),
|
||||
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_OUTPUT_TOKEN_MAX: number("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
|
||||
OPENCODE_EXPERIMENTAL_LSP_TOOL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
|
||||
OPENCODE_EXPERIMENTAL_PLAN_MODE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
|
||||
OPENCODE_EXPERIMENTAL_SCOUT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SCOUT"),
|
||||
OPENCODE_ENABLE_PARALLEL: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"),
|
||||
OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"],
|
||||
OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"],
|
||||
OPENCODE_DB: process.env["OPENCODE_DB"],
|
||||
OPENCODE_SKIP_MIGRATIONS: truthy("OPENCODE_SKIP_MIGRATIONS"),
|
||||
OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"),
|
||||
|
||||
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
|
||||
OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"),
|
||||
OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
|
||||
OPENCODE_EXPERIMENTAL_SESSION_SWITCHING: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SESSION_SWITCHING"),
|
||||
|
||||
// Evaluated at access time (not module load) because tests, the CLI, and
|
||||
// external tooling set these env vars at runtime.
|
||||
|
||||
@@ -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") {}
|
||||
@@ -1,13 +0,0 @@
|
||||
export * as Session from "./session"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { withStatics } from "./schema"
|
||||
import { Identifier } from "./util/identifier"
|
||||
|
||||
export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
|
||||
Schema.brand("SessionID"),
|
||||
withStatics((schema) => ({
|
||||
descending: (id?: string) => schema.make(id ?? "ses_" + Identifier.descending()),
|
||||
})),
|
||||
)
|
||||
export type ID = typeof ID.Type
|
||||
@@ -1,21 +1,14 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Fiber, Layer, Option, Stream } from "effect"
|
||||
import { DateTime, Effect, Layer, Option } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Instance } from "@opencode-ai/core/instance"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
|
||||
const it = testEffect(
|
||||
Catalog.layer.pipe(
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(PluginV2.defaultLayer),
|
||||
Layer.provideMerge(locationLayer),
|
||||
),
|
||||
)
|
||||
const instanceLayer = Layer.succeed(Instance.Service, Instance.Service.of({ directory: "test" }))
|
||||
const it = testEffect(Catalog.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer), Layer.provide(instanceLayer)))
|
||||
|
||||
describe("CatalogV2", () => {
|
||||
it.effect("normalizes provider baseURL into endpoint url", () =>
|
||||
@@ -76,31 +69,6 @@ describe("CatalogV2", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("publishes model updated events", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const events = yield* EventV2.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const modelID = ModelV2.ID.make("model")
|
||||
const fiber = yield* events
|
||||
.subscribe(Catalog.Event.ModelUpdated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
|
||||
yield* Effect.yieldNow
|
||||
yield* catalog.provider.update(providerID, () => {})
|
||||
yield* catalog.model.update(providerID, modelID, (model) => {
|
||||
model.name = "Updated Model"
|
||||
})
|
||||
const event = Array.from(yield* Fiber.join(fiber))[0]
|
||||
|
||||
expect(event?.type).toBe("catalog.model.updated")
|
||||
expect(event?.data.model.providerID).toBe(providerID)
|
||||
expect(event?.data.model.id).toBe(modelID)
|
||||
expect(event?.data.model.name).toBe("Updated Model")
|
||||
expect(event?.location).toEqual({ directory: "test" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves unknown model endpoint from provider endpoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of({ directory: "project", workspaceID: "workspace" }),
|
||||
)
|
||||
const it = testEffect(EventV2.layer.pipe(Layer.provideMerge(locationLayer)))
|
||||
const itWithoutLocation = testEffect(EventV2.layer)
|
||||
|
||||
const Message = EventV2.define({
|
||||
type: "test.message",
|
||||
schema: {
|
||||
text: Schema.String,
|
||||
},
|
||||
})
|
||||
|
||||
const GlobalMessage = EventV2.define({
|
||||
type: "test.global",
|
||||
schema: {
|
||||
text: Schema.String,
|
||||
},
|
||||
})
|
||||
|
||||
const VersionedMessage = EventV2.define({
|
||||
type: "test.versioned",
|
||||
version: 2,
|
||||
schema: {
|
||||
text: Schema.String,
|
||||
},
|
||||
})
|
||||
|
||||
describe("EventV2", () => {
|
||||
it.effect("publishes events with the current location", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const fiber = yield* events.subscribe(Message).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
const event = yield* events.publish(Message, { text: "hello" })
|
||||
const received = Array.from(yield* Fiber.join(fiber))
|
||||
|
||||
expect(received).toEqual([event])
|
||||
expect(event.type).toBe("test.message")
|
||||
expect(event).not.toHaveProperty("version")
|
||||
expect(event.data).toEqual({ text: "hello" })
|
||||
expect(event.location).toEqual({ directory: "project", workspaceID: "workspace" })
|
||||
}),
|
||||
)
|
||||
|
||||
itWithoutLocation.effect("omits location when no location is available", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const event = yield* events.publish(GlobalMessage, { text: "hello" })
|
||||
|
||||
expect(event).not.toHaveProperty("location")
|
||||
expect(event.type).toBe("test.global")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("publishes definition version", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const event = yield* events.publish(VersionedMessage, { text: "hello" })
|
||||
|
||||
expect(event.type).toBe("test.versioned")
|
||||
expect(event.version).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stores definitions in the exported registry", () =>
|
||||
Effect.sync(() => {
|
||||
expect(EventV2.registry.get(Message.type)).toBe(Message)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("publishes to typed and wildcard subscriptions", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const typed = yield* events.subscribe(Message).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
const wildcard = yield* events.all().pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
const event = yield* events.publish(Message, { text: "hello" })
|
||||
|
||||
expect(Array.from(yield* Fiber.join(typed))).toEqual([event])
|
||||
expect(Array.from(yield* Fiber.join(wildcard))).toEqual([event])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("runs sync handlers inline", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const received = new Array<EventV2.Payload>()
|
||||
const unsubscribe = yield* events.sync((event) =>
|
||||
Effect.sync(() => {
|
||||
received.push(event)
|
||||
}),
|
||||
)
|
||||
|
||||
const event = yield* events.publish(Message, { text: "hello" })
|
||||
yield* unsubscribe
|
||||
yield* events.publish(Message, { text: "after unsubscribe" })
|
||||
|
||||
expect(received).toEqual([event])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("runs sync handlers before publishing to streams", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const received = new Array<string>()
|
||||
const fiber = yield* events.all().pipe(
|
||||
Stream.take(1),
|
||||
Stream.runForEach(() => Effect.sync(() => received.push("stream"))),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* events.sync((event) =>
|
||||
Effect.sync(() => {
|
||||
received.push(event.type)
|
||||
}),
|
||||
)
|
||||
|
||||
yield* Effect.yieldNow
|
||||
yield* events.publish(Message, { text: "hello" })
|
||||
yield* Fiber.join(fiber)
|
||||
|
||||
expect(received).toEqual([Message.type, "stream"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Layer, Option } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Instance } from "@opencode-ai/core/instance"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
|
||||
@@ -9,7 +9,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { it, model, provider, withEnv } from "./provider-helper"
|
||||
|
||||
const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
|
||||
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
|
||||
const instanceLayer = Layer.succeed(Instance.Service, Instance.Service.of({ directory: "test" }))
|
||||
|
||||
describe("OpencodePlugin", () => {
|
||||
it.effect("uses a public key and cancels paid models without credentials", () =>
|
||||
@@ -192,6 +192,6 @@ describe("OpencodePlugin", () => {
|
||||
const selected = yield* catalog.model.small(providerID)
|
||||
|
||||
expect(Option.getOrUndefined(selected)?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
|
||||
}).pipe(Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(locationLayer)))),
|
||||
}).pipe(Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(instanceLayer)))),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.51",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -84,7 +84,6 @@ export function createMainWindow() {
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
title: "OpenCode",
|
||||
icon: iconPath(),
|
||||
backgroundColor,
|
||||
@@ -143,7 +142,6 @@ export function createLoadingWindow() {
|
||||
resizable: false,
|
||||
center: true,
|
||||
show: true,
|
||||
autoHideMenuBar: true,
|
||||
icon: iconPath(),
|
||||
backgroundColor,
|
||||
...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.51",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.15.0"
|
||||
version = "1.14.51"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.51/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.51/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.51/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.51/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.51/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.51",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.51",
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.51",
|
||||
"name": "@opencode-ai/llm",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -128,8 +128,17 @@ See `specs/effect/migration.md` for the compact pattern reference and examples.
|
||||
|
||||
Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation rather than storing `Fiber | undefined` or `Promise | undefined` manually. See `specs/effect/migration.md` for the full pattern.
|
||||
|
||||
## Callback boundaries
|
||||
## Instance.bind — ALS for native callbacks
|
||||
|
||||
Use `EffectBridge` for native or external callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, plugin callbacks, etc.) that need to re-enter Effect services with instance/workspace context.
|
||||
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called.
|
||||
|
||||
Plain async code should pass explicit context or stay inside an Effect fiber; do not add ambient instance context shims.
|
||||
Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish` or anything that reads `Instance.directory`.
|
||||
|
||||
You do not need it for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers.
|
||||
|
||||
```typescript
|
||||
const cb = Instance.bind((err, evts) => {
|
||||
Bus.publish(MyEvent, { ... })
|
||||
})
|
||||
nativeAddon.subscribe(dir, cb)
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.0",
|
||||
"version": "1.14.51",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -244,7 +244,6 @@ for (const item of targets) {
|
||||
{
|
||||
name,
|
||||
version: Script.version,
|
||||
preferUnplugged: true,
|
||||
os: [item.os],
|
||||
cpu: [item.arch],
|
||||
},
|
||||
|
||||
@@ -1,189 +1,102 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import childProcess from "child_process"
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { createRequire } from "module"
|
||||
import os from "os"
|
||||
import { fileURLToPath } from "url"
|
||||
import { createRequire } from "module"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const require = createRequire(import.meta.url)
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8"))
|
||||
|
||||
const platformMap = {
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
win32: "windows",
|
||||
}
|
||||
const archMap = {
|
||||
x64: "x64",
|
||||
arm64: "arm64",
|
||||
arm: "arm",
|
||||
function detectPlatformAndArch() {
|
||||
// Map platform names
|
||||
let platform
|
||||
switch (os.platform()) {
|
||||
case "darwin":
|
||||
platform = "darwin"
|
||||
break
|
||||
case "linux":
|
||||
platform = "linux"
|
||||
break
|
||||
case "win32":
|
||||
platform = "windows"
|
||||
break
|
||||
default:
|
||||
platform = os.platform()
|
||||
break
|
||||
}
|
||||
|
||||
// Map architecture names
|
||||
let arch
|
||||
switch (os.arch()) {
|
||||
case "x64":
|
||||
arch = "x64"
|
||||
break
|
||||
case "arm64":
|
||||
arch = "arm64"
|
||||
break
|
||||
case "arm":
|
||||
arch = "arm"
|
||||
break
|
||||
default:
|
||||
arch = os.arch()
|
||||
break
|
||||
}
|
||||
|
||||
return { platform, arch }
|
||||
}
|
||||
|
||||
const platform = platformMap[os.platform()] ?? os.platform()
|
||||
const arch = archMap[os.arch()] ?? os.arch()
|
||||
const base = `opencode-${platform}-${arch}`
|
||||
const sourceBinary = platform === "windows" ? "opencode.exe" : "opencode"
|
||||
const targetBinary = path.join(__dirname, "bin", "opencode.exe")
|
||||
function findBinary() {
|
||||
const { platform, arch } = detectPlatformAndArch()
|
||||
const packageName = `opencode-${platform}-${arch}`
|
||||
const binaryName = platform === "windows" ? "opencode.exe" : "opencode"
|
||||
|
||||
function supportsAvx2() {
|
||||
if (arch !== "x64") return false
|
||||
try {
|
||||
// Use require.resolve to find the package
|
||||
const packageJsonPath = require.resolve(`${packageName}/package.json`)
|
||||
const packageDir = path.dirname(packageJsonPath)
|
||||
const binaryPath = path.join(packageDir, "bin", binaryName)
|
||||
|
||||
if (platform === "linux") {
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
throw new Error(`Binary not found at ${binaryPath}`)
|
||||
}
|
||||
|
||||
return { binaryPath, binaryName }
|
||||
} catch (error) {
|
||||
throw new Error(`Could not find package ${packageName}: ${error.message}`, { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
if (os.platform() === "win32") {
|
||||
// On Windows, the .exe is already included in the package and bin field points to it
|
||||
// No postinstall setup needed
|
||||
console.log("Windows detected: binary setup not needed (using packaged .exe)")
|
||||
return
|
||||
}
|
||||
|
||||
// On non-Windows platforms, just verify the binary package exists
|
||||
// Don't replace the wrapper script - it handles binary execution
|
||||
const { binaryPath } = findBinary()
|
||||
const target = path.join(__dirname, "bin", ".opencode")
|
||||
if (fs.existsSync(target)) fs.unlinkSync(target)
|
||||
try {
|
||||
return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8"))
|
||||
fs.linkSync(binaryPath, target)
|
||||
} catch {
|
||||
return false
|
||||
fs.copyFileSync(binaryPath, target)
|
||||
}
|
||||
fs.chmodSync(target, 0o755)
|
||||
} catch (error) {
|
||||
console.error("Failed to setup opencode binary:", error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (platform === "darwin") {
|
||||
try {
|
||||
const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], {
|
||||
encoding: "utf8",
|
||||
timeout: 1500,
|
||||
})
|
||||
if (result.status !== 0) return false
|
||||
return (result.stdout || "").trim() === "1"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === "windows") {
|
||||
const command =
|
||||
'(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)'
|
||||
|
||||
for (const executable of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) {
|
||||
try {
|
||||
const result = childProcess.spawnSync(executable, ["-NoProfile", "-NonInteractive", "-Command", command], {
|
||||
encoding: "utf8",
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
})
|
||||
if (result.status !== 0) continue
|
||||
const output = (result.stdout || "").trim().toLowerCase()
|
||||
if (output === "true" || output === "1") return true
|
||||
if (output === "false" || output === "0") return false
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function isMusl() {
|
||||
if (platform !== "linux") return false
|
||||
|
||||
try {
|
||||
if (fs.existsSync("/etc/alpine-release")) return true
|
||||
} catch {
|
||||
// Ignore filesystem probes that are blocked by the host.
|
||||
}
|
||||
|
||||
try {
|
||||
const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" })
|
||||
return `${result.stdout || ""}${result.stderr || ""}`.toLowerCase().includes("musl")
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function packageNames() {
|
||||
const baseline = arch === "x64" && !supportsAvx2()
|
||||
|
||||
if (platform === "linux") {
|
||||
if (isMusl()) {
|
||||
if (arch === "x64")
|
||||
return baseline
|
||||
? [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base]
|
||||
: [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`]
|
||||
return [`${base}-musl`, base]
|
||||
}
|
||||
|
||||
if (arch === "x64")
|
||||
return baseline
|
||||
? [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`]
|
||||
: [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`]
|
||||
return [base, `${base}-musl`]
|
||||
}
|
||||
|
||||
if (arch === "x64") return baseline ? [`${base}-baseline`, base] : [base, `${base}-baseline`]
|
||||
return [base]
|
||||
}
|
||||
|
||||
function resolveBinary(name) {
|
||||
const packageJsonPath = require.resolve(`${name}/package.json`)
|
||||
const binaryPath = path.join(path.dirname(packageJsonPath), "bin", sourceBinary)
|
||||
if (!fs.existsSync(binaryPath)) throw new Error(`Binary not found at ${binaryPath}`)
|
||||
return binaryPath
|
||||
}
|
||||
|
||||
function installPackage(name) {
|
||||
const version = packageJson.optionalDependencies?.[name]
|
||||
if (!version) return
|
||||
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-install-"))
|
||||
try {
|
||||
const result = childProcess.spawnSync(
|
||||
"npm",
|
||||
["install", "--ignore-scripts", "--no-save", "--loglevel=error", "--prefix", temp, `${name}@${version}`],
|
||||
{ stdio: "inherit", windowsHide: true },
|
||||
)
|
||||
if (result.status !== 0) return
|
||||
const packageDir = path.join(temp, "node_modules", name)
|
||||
copyBinary(path.join(packageDir, "bin", sourceBinary), targetBinary)
|
||||
return true
|
||||
} finally {
|
||||
fs.rmSync(temp, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function copyBinary(source, target) {
|
||||
if (!fs.existsSync(source)) throw new Error(`Binary not found at ${source}`)
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true })
|
||||
if (fs.existsSync(target)) fs.unlinkSync(target)
|
||||
try {
|
||||
fs.linkSync(source, target)
|
||||
} catch {
|
||||
fs.copyFileSync(source, target)
|
||||
}
|
||||
fs.chmodSync(target, 0o755)
|
||||
}
|
||||
|
||||
function verifyBinary() {
|
||||
const result = childProcess.spawnSync(targetBinary, ["--version"], {
|
||||
encoding: "utf8",
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
})
|
||||
return result.status === 0
|
||||
}
|
||||
|
||||
function main() {
|
||||
for (const name of packageNames()) {
|
||||
try {
|
||||
copyBinary(resolveBinary(name), targetBinary)
|
||||
if (verifyBinary()) return
|
||||
} catch {
|
||||
if (installPackage(name) && verifyBinary()) return
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`It seems your package manager failed to install the right opencode CLI package. Try manually installing ${packageNames()
|
||||
.map((name) => JSON.stringify(name))
|
||||
.join(" or ")}.`,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
main()
|
||||
void main()
|
||||
} catch (error) {
|
||||
console.error(error.message)
|
||||
process.exit(1)
|
||||
console.error("Postinstall script error:", error.message)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
@@ -32,32 +32,22 @@ console.log("binaries", binaries)
|
||||
const version = Object.values(binaries)[0]
|
||||
|
||||
await $`mkdir -p ./dist/${pkg.name}`
|
||||
await $`mkdir -p ./dist/${pkg.name}/bin`
|
||||
await $`cp -r ./bin ./dist/${pkg.name}/bin`
|
||||
await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs`
|
||||
await Bun.file(`./dist/${pkg.name}/LICENSE`).write(await Bun.file("../../LICENSE").text())
|
||||
await Bun.file(`./dist/${pkg.name}/bin/${pkg.name}.exe`).write(
|
||||
[
|
||||
"#!/usr/bin/env node",
|
||||
"console.error('The opencode native binary was not installed. Run `node postinstall.mjs` from the opencode-ai package directory to finish setup.')",
|
||||
"process.exit(1)",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
await Bun.file(`./dist/${pkg.name}/package.json`).write(
|
||||
JSON.stringify(
|
||||
{
|
||||
name: pkg.name + "-ai",
|
||||
bin: {
|
||||
[pkg.name]: `./bin/${pkg.name}.exe`,
|
||||
[pkg.name]: `./bin/${pkg.name}`,
|
||||
},
|
||||
scripts: {
|
||||
postinstall: "node ./postinstall.mjs",
|
||||
postinstall: "bun ./postinstall.mjs || node ./postinstall.mjs",
|
||||
},
|
||||
version: version,
|
||||
license: pkg.license,
|
||||
os: ["darwin", "linux", "win32"],
|
||||
cpu: ["arm64", "x64"],
|
||||
optionalDependencies: binaries,
|
||||
},
|
||||
null,
|
||||
|
||||
@@ -6,6 +6,7 @@ Current status on this branch:
|
||||
|
||||
- `src/` has 5 `makeRuntime(...)` call sites total.
|
||||
- 2 are intentionally excluded from this checklist: `src/bus/index.ts` and `src/effect/cross-spawn-spawner.ts`.
|
||||
- 1 is tracked primarily by the instance-context migration rather than facade removal: `src/project/instance.ts`.
|
||||
- That leaves 2 live runtime-backed service facades still worth tracking here: `src/npm/index.ts` and `src/cli/cmd/tui/config/tui.ts`.
|
||||
|
||||
Recent progress:
|
||||
@@ -17,6 +18,7 @@ Recent progress:
|
||||
|
||||
- `src/cli/cmd/tui/config/tui.ts` still exports `makeRuntime(...)` plus async facade helpers for `get()` and `waitForDependencies()`.
|
||||
- `src/npm/index.ts` still exports `makeRuntime(...)` plus async facade helpers for `install()`, `add()`, `outdated()`, and `which()`.
|
||||
- `src/project/instance.ts` still uses a dedicated runtime for project boot, but that file is really part of the broader legacy instance-context transition tracked in `instance-context.md`.
|
||||
|
||||
## Completed Batches
|
||||
|
||||
@@ -190,6 +192,7 @@ Most of the original facade-removal backlog is already done. The practical remai
|
||||
|
||||
1. remove the `Npm` runtime-backed facade from `src/npm/index.ts`
|
||||
2. remove the `TuiConfig` runtime-backed facade from `src/cli/cmd/tui/config/tui.ts`
|
||||
3. keep `src/project/instance.ts` in the separate instance-context migration, not this checklist
|
||||
|
||||
## Checklist
|
||||
|
||||
|
||||
@@ -197,9 +197,13 @@ For background loops, use `Effect.repeat` or `Effect.schedule` with
|
||||
|
||||
[`EffectBridge`](../../src/effect/bridge.ts) is the sanctioned helper for
|
||||
Promise/callback interop that needs to preserve instance/workspace context.
|
||||
It preserves explicit `InstanceRef` / `WorkspaceRef` context for effects run
|
||||
through the bridge. Plain JS callbacks that need instance data should receive
|
||||
that data explicitly.
|
||||
Keep it, but reduce its dependency on legacy `Instance.current` /
|
||||
`Instance.restore` over time.
|
||||
|
||||
`Instance.bind` / `Instance.restore` are transitional legacy tools. Use
|
||||
them only for native callbacks that still require legacy ALS context. Do
|
||||
not use them for `setTimeout`, `Promise.then`, `EventEmitter.on`, or
|
||||
Effect fibers.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -1,13 +1,309 @@
|
||||
# Instance Context
|
||||
# Instance context migration
|
||||
|
||||
Instance selection is now Effect-provided context.
|
||||
Practical plan for retiring the promise-backed / ALS-backed `Instance` helper in `src/project/instance.ts` and moving instance selection fully into Effect-provided scope.
|
||||
|
||||
Use these APIs:
|
||||
## Goal
|
||||
|
||||
- `InstanceRef` for the current project context.
|
||||
- `WorkspaceRef` for the current workspace id.
|
||||
- `InstanceState.context` / `InstanceState.directory` inside Effect services that require an instance.
|
||||
- `InstanceStore` at entry boundaries that need to load, reload, or dispose project contexts.
|
||||
- `EffectBridge` for native, plugin, or plain JavaScript callback boundaries that need to re-enter Effect with captured refs.
|
||||
End state:
|
||||
|
||||
Do not add new ambient instance globals. Promise and callback boundaries should either stay in Effect, use `EffectBridge`, or pass the required context explicitly.
|
||||
- request, CLI, TUI, and tool entrypoints shift into an instance through Effect, not `Instance.provide(...)`
|
||||
- Effect code reads the current instance from `InstanceRef` or its eventual replacement, not from ALS-backed sync getters
|
||||
- per-directory boot, caching, and disposal are scoped Effect resources, not a module-level `Map<string, Promise<InstanceContext>>`
|
||||
- ALS remains only as a temporary bridge for native callback APIs that fire outside the Effect fiber tree
|
||||
|
||||
## Current split
|
||||
|
||||
Today `src/project/instance.ts` still owns two separate concerns:
|
||||
|
||||
- ambient current-instance context through `LocalContext` / `AsyncLocalStorage`
|
||||
- per-directory boot and deduplication through `cache: Map<string, Promise<InstanceContext>>`
|
||||
|
||||
At the same time, the Effect side already exists:
|
||||
|
||||
- `src/effect/instance-ref.ts` provides `InstanceRef` and `WorkspaceRef`
|
||||
- `src/effect/run-service.ts` already attaches those refs when a runtime starts inside an active instance ALS context
|
||||
- `src/effect/instance-state.ts` already prefers `InstanceRef` and only falls back to ALS when needed
|
||||
|
||||
That means the migration is not "invent instance context in Effect". The migration is "stop relying on the legacy helper as the primary source of truth".
|
||||
|
||||
## End state shape
|
||||
|
||||
Near-term target shape:
|
||||
|
||||
```ts
|
||||
InstanceScope.with({ directory, workspaceID }, effect)
|
||||
```
|
||||
|
||||
Responsibilities of `InstanceScope.with(...)`:
|
||||
|
||||
- resolve `directory`, `project`, and `worktree`
|
||||
- acquire or reuse the scoped per-directory instance environment
|
||||
- provide `InstanceRef` and `WorkspaceRef`
|
||||
- run the caller's Effect inside that environment
|
||||
|
||||
Code inside the boundary should then do one of these:
|
||||
|
||||
```ts
|
||||
const ctx = yield * InstanceState.context
|
||||
const dir = yield * InstanceState.directory
|
||||
```
|
||||
|
||||
Long-term, once `InstanceState` itself is replaced by keyed layers / `LayerMap`, those reads can move to an `InstanceContext` service without changing the outer migration order.
|
||||
|
||||
## Migration phases
|
||||
|
||||
### Phase 1: stop expanding the legacy surface
|
||||
|
||||
Rules for all new code:
|
||||
|
||||
- do not add new `Instance.directory`, `Instance.worktree`, `Instance.project`, or `Instance.current` reads inside Effect code
|
||||
- do not add new `Instance.provide(...)` boundaries unless there is no Effect-native seam yet
|
||||
- use `InstanceState.context`, `InstanceState.directory`, or an explicit `ctx` parameter inside Effect code
|
||||
|
||||
Success condition:
|
||||
|
||||
- the file inventory below only shrinks from here
|
||||
|
||||
### Phase 2: remove direct sync getter reads from Effect services
|
||||
|
||||
Convert Effect services first, before replacing the top-level boundary. These modules already run inside Effect and mostly need `yield* InstanceState.context` or a yielded `ctx` instead of ambient sync access.
|
||||
|
||||
Primary batch, highest payoff:
|
||||
|
||||
- `src/file/index.ts`
|
||||
- `src/lsp/server.ts`
|
||||
- `src/worktree/index.ts`
|
||||
- `src/file/watcher.ts`
|
||||
- `src/format/formatter.ts`
|
||||
- `src/session/index.ts`
|
||||
- `src/project/vcs.ts`
|
||||
|
||||
Mechanical replacement rule:
|
||||
|
||||
- `Instance.directory` -> `ctx.directory` or `yield* InstanceState.directory`
|
||||
- `Instance.worktree` -> `ctx.worktree`
|
||||
- `Instance.project` -> `ctx.project`
|
||||
|
||||
Do not thread strings manually through every public method if the service already has access to Effect context.
|
||||
|
||||
### Phase 3: convert entry boundaries to provide instance refs directly
|
||||
|
||||
After the service bodies stop assuming ALS, move the top-level boundaries to shift into Effect explicitly.
|
||||
|
||||
Main boundaries:
|
||||
|
||||
- HTTP server middleware and experimental `HttpApi` entrypoints
|
||||
- CLI commands
|
||||
- TUI worker / attach / thread entrypoints
|
||||
- tool execution entrypoints
|
||||
|
||||
These boundaries should become Effect-native wrappers that:
|
||||
|
||||
- decode directory / workspace inputs
|
||||
- resolve the instance context once
|
||||
- provide `InstanceRef` and `WorkspaceRef`
|
||||
- run the requested Effect
|
||||
|
||||
At that point `Instance.provide(...)` becomes a legacy adapter instead of the normal code path.
|
||||
|
||||
### Phase 4: replace promise boot cache with scoped instance runtime
|
||||
|
||||
Once boundaries and services both rely on Effect context, replace the module-level promise cache in `src/project/instance.ts`.
|
||||
|
||||
Target replacement:
|
||||
|
||||
- keyed scoped runtime or keyed layer acquisition for each directory
|
||||
- reuse via `ScopedCache`, `LayerMap`, or another keyed Effect resource manager
|
||||
- cleanup performed by scope finalizers instead of `disposeAll()` iterating a Promise map
|
||||
|
||||
This phase should absorb the current responsibilities of:
|
||||
|
||||
- `cache` in `src/project/instance.ts`
|
||||
- `boot(...)`
|
||||
- most of `disposeInstance(...)`
|
||||
- manual `reload(...)` / `disposeAll()` fan-out logic
|
||||
|
||||
### Phase 5: shrink ALS to callback bridges only
|
||||
|
||||
Keep ALS only where a library invokes callbacks outside the Effect fiber tree and we still need to call code that reads instance context synchronously.
|
||||
|
||||
Known bridge cases today:
|
||||
|
||||
- `src/file/watcher.ts`
|
||||
- `src/session/llm.ts`
|
||||
- some LSP and plugin callback paths
|
||||
|
||||
If those libraries become fully wrapped in Effect services, the remaining `Instance.bind(...)` uses can disappear too.
|
||||
|
||||
### Phase 6: delete the legacy sync API
|
||||
|
||||
Only after earlier phases land:
|
||||
|
||||
- remove broad use of `Instance.current`, `Instance.directory`, `Instance.worktree`, `Instance.project`
|
||||
- reduce `src/project/instance.ts` to a thin compatibility shim or delete it entirely
|
||||
- remove the ALS fallback from `InstanceState.context`
|
||||
|
||||
## Inventory of direct legacy usage
|
||||
|
||||
Direct legacy usage means any source file that still calls one of:
|
||||
|
||||
- `Instance.current`
|
||||
- `Instance.directory`
|
||||
- `Instance.worktree`
|
||||
- `Instance.project`
|
||||
- `Instance.provide(...)`
|
||||
- `Instance.bind(...)`
|
||||
- `Instance.restore(...)`
|
||||
- `Instance.reload(...)`
|
||||
- `Instance.dispose()` / `Instance.disposeAll()`
|
||||
|
||||
Current total: `56` files in `packages/opencode/src`.
|
||||
|
||||
### Core bridge and plumbing
|
||||
|
||||
These files define or adapt the current bridge. They should change last, after callers have moved.
|
||||
|
||||
- `src/project/instance.ts`
|
||||
- `src/effect/run-service.ts`
|
||||
- `src/effect/instance-state.ts`
|
||||
- `src/project/bootstrap.ts`
|
||||
- `src/config/config.ts`
|
||||
|
||||
Migration rule:
|
||||
|
||||
- keep these as compatibility glue until the outer boundaries and inner services stop depending on ALS
|
||||
|
||||
### HTTP and server boundaries
|
||||
|
||||
These are the current request-entry seams that still create or consume instance context through the legacy helper.
|
||||
|
||||
- `src/server/routes/instance/middleware.ts`
|
||||
- `src/server/routes/instance/index.ts`
|
||||
- `src/server/routes/instance/project.ts`
|
||||
- `src/server/routes/control/workspace.ts`
|
||||
- `src/server/routes/instance/file.ts`
|
||||
- `src/server/routes/instance/experimental.ts`
|
||||
- `src/server/routes/global.ts`
|
||||
|
||||
Migration rule:
|
||||
|
||||
- move these to explicit Effect entrypoints that provide `InstanceRef` / `WorkspaceRef`
|
||||
- do not move these first; first reduce the number of downstream handlers and services that still expect ambient ALS
|
||||
|
||||
### CLI and TUI boundaries
|
||||
|
||||
These commands still enter an instance through `Instance.provide(...)` or read sync getters directly.
|
||||
|
||||
- `src/cli/bootstrap.ts`
|
||||
- `src/cli/cmd/agent.ts`
|
||||
- `src/cli/cmd/debug/agent.ts`
|
||||
- `src/cli/cmd/debug/ripgrep.ts`
|
||||
- `src/cli/cmd/github.ts`
|
||||
- `src/cli/cmd/import.ts`
|
||||
- `src/cli/cmd/mcp.ts`
|
||||
- `src/cli/cmd/models.ts`
|
||||
- `src/cli/cmd/plug.ts`
|
||||
- `src/cli/cmd/pr.ts`
|
||||
- `src/cli/cmd/providers.ts`
|
||||
- `src/cli/cmd/stats.ts`
|
||||
- `src/cli/cmd/tui/attach.ts`
|
||||
- `src/cli/cmd/tui/plugin/runtime.ts`
|
||||
- `src/cli/cmd/tui/thread.ts`
|
||||
- `src/cli/cmd/tui/worker.ts`
|
||||
|
||||
Migration rule:
|
||||
|
||||
- converge these on one shared `withInstance(...)` Effect entry helper instead of open-coded `Instance.provide(...)`
|
||||
- after that helper is proven, inline the legacy implementation behind an Effect-native scope provider
|
||||
|
||||
### Tool boundary code
|
||||
|
||||
These tools mostly use direct getters for path resolution and repo-relative display logic.
|
||||
|
||||
- `src/tool/apply_patch.ts`
|
||||
- `src/tool/bash.ts`
|
||||
- `src/tool/edit.ts`
|
||||
- `src/tool/lsp.ts`
|
||||
- `src/tool/plan.ts`
|
||||
- `src/tool/read.ts`
|
||||
- `src/tool/write.ts`
|
||||
|
||||
Migration rule:
|
||||
|
||||
- expose the current instance as an explicit Effect dependency for tool execution
|
||||
- keep path logic local; avoid introducing another global singleton for tool state
|
||||
|
||||
### Effect services still reading ambient instance state
|
||||
|
||||
These modules are already the best near-term migration targets because they are in Effect code but still read sync getters from the legacy helper.
|
||||
|
||||
- `src/agent/agent.ts`
|
||||
- `src/cli/cmd/tui/config/tui-migrate.ts`
|
||||
- `src/file/index.ts`
|
||||
- `src/file/watcher.ts`
|
||||
- `src/format/formatter.ts`
|
||||
- `src/lsp/client.ts`
|
||||
- `src/lsp/index.ts`
|
||||
- `src/lsp/server.ts`
|
||||
- `src/mcp/index.ts`
|
||||
- `src/project/vcs.ts`
|
||||
- `src/provider/provider.ts`
|
||||
- `src/pty/index.ts`
|
||||
- `src/session/session.ts`
|
||||
- `src/session/instruction.ts`
|
||||
- `src/session/llm.ts`
|
||||
- `src/session/system.ts`
|
||||
- `src/sync/index.ts`
|
||||
- `src/worktree/index.ts`
|
||||
|
||||
Migration rule:
|
||||
|
||||
- replace direct getter reads with `yield* InstanceState.context` or a yielded `ctx`
|
||||
- isolate `Instance.bind(...)` callers and convert only the truly callback-driven edges to bridge mode
|
||||
|
||||
### Highest-churn hotspots
|
||||
|
||||
Current highest direct-usage counts by file:
|
||||
|
||||
- `src/file/index.ts` - `18`
|
||||
- `src/lsp/server.ts` - `14`
|
||||
- `src/worktree/index.ts` - `12`
|
||||
- `src/file/watcher.ts` - `9`
|
||||
- `src/cli/cmd/mcp.ts` - `8`
|
||||
- `src/format/formatter.ts` - `8`
|
||||
- `src/tool/apply_patch.ts` - `8`
|
||||
- `src/cli/cmd/github.ts` - `7`
|
||||
|
||||
These files should drive the first measurable burn-down.
|
||||
|
||||
## Recommended implementation order
|
||||
|
||||
1. Migrate direct getter reads inside Effect services, starting with `file`, `lsp`, `worktree`, `format`, and `session`.
|
||||
2. Add one shared Effect-native boundary helper for CLI / tool / HTTP entrypoints so we stop open-coding `Instance.provide(...)`.
|
||||
3. Move experimental `HttpApi` entrypoints to that helper so the new server stack proves the pattern.
|
||||
4. Convert remaining CLI and tool boundaries.
|
||||
5. Replace the promise cache with a keyed scoped runtime or keyed layer map.
|
||||
6. Delete ALS fallback paths once only callback bridges still depend on them.
|
||||
|
||||
## Definition of done
|
||||
|
||||
This migration is done when all of the following are true:
|
||||
|
||||
- new requests and commands enter an instance by providing Effect context, not ALS
|
||||
- Effect services no longer read `Instance.directory`, `Instance.worktree`, `Instance.project`, or `Instance.current`
|
||||
- `Instance.provide(...)` is gone from normal request / CLI / tool execution
|
||||
- per-directory boot and disposal are handled by scoped Effect resources
|
||||
- `Instance.bind(...)` is either gone or confined to a tiny set of native callback adapters
|
||||
|
||||
## Tracker and worktree
|
||||
|
||||
Active tracker items:
|
||||
|
||||
- `lh7l73` - overall `HttpApi` migration
|
||||
- `yobwlk` - remove direct `Instance.*` reads inside Effect services
|
||||
- `7irl1e` - replace `InstanceState` / legacy instance caching with keyed Effect layers
|
||||
|
||||
Dedicated worktree for this transition:
|
||||
|
||||
- path: `/Users/kit/code/open-source/opencode-worktrees/instance-effect-shift`
|
||||
- branch: `kit/instance-effect-shift`
|
||||
|
||||
@@ -24,6 +24,10 @@ Small follow-ups that do not fit neatly into the main facade, route, tool, or sc
|
||||
- [ ] `cli/cmd/tui/config/tui.ts` - switch TUI config loading from async `ConfigPaths.*` wrappers to the `ConfigPaths.Service` once that service exists.
|
||||
- [ ] `cli/cmd/tui/config/tui-migrate.ts` - decide whether to leave this as a plain async module using wrapper functions or effectify it fully after `ConfigPaths.Service` lands.
|
||||
|
||||
## Instance cleanup
|
||||
|
||||
- [ ] `project/instance.ts` - keep shrinking the legacy ALS / Promise cache after the remaining `Instance.*` callers move over.
|
||||
|
||||
## Notes
|
||||
|
||||
- Prefer small, semantics-preserving config migrations. Config precedence, legacy key migration, and plugin origin tracking are easy to break accidentally.
|
||||
|
||||
@@ -64,11 +64,13 @@ P6 OA
|
||||
explicit and testable instead of mutable module state.
|
||||
Shrinks: [`global.ts`](../../../core/src/global.ts) import-time side
|
||||
effects, mutable `Global.Path` overrides, and its `Flag` dependency.
|
||||
- `INST` Instance context — keep project context explicit through Effect refs
|
||||
and bridge boundaries.
|
||||
- `INST` Instance shim — remove ambient `Instance` usage and old ALS
|
||||
access patterns.
|
||||
Shrinks: [`src/project/instance.ts`](../../src/project/instance.ts).
|
||||
- `BRIDGE` Promise/callback interop — keep bridge helpers, but reduce
|
||||
legacy ALS coupling.
|
||||
Shrinks: ad hoc Promise/callback re-entry code.
|
||||
Shrinks: [`src/effect/bridge.ts`](../../src/effect/bridge.ts)
|
||||
dependency on [`project/instance.ts`](../../src/project/instance.ts).
|
||||
- `PROC` AppProcess migration — prefer `AppProcess.Service` over raw
|
||||
process wrappers.
|
||||
Shrinks: direct spawn callsites and legacy process helpers.
|
||||
@@ -219,13 +221,74 @@ Next PR candidates:
|
||||
|
||||
## P4: Instance And Bridge
|
||||
|
||||
Instance context migration is complete for the legacy sync shim. Promise and callback interop continues through [`effect/bridge.ts`](../../src/effect/bridge.ts).
|
||||
[`project/instance.ts`](../../src/project/instance.ts) is the deletion
|
||||
target. [`effect/bridge.ts`](../../src/effect/bridge.ts) is not a near-term
|
||||
deletion target; Promise/callback interop will continue to exist.
|
||||
|
||||
Current rules:
|
||||
Goal:
|
||||
|
||||
- Effect services read instance data from `InstanceRef`, `WorkspaceRef`, `InstanceState`, or explicit arguments.
|
||||
- Plain JavaScript callback boundaries use `EffectBridge` or explicit context arguments.
|
||||
- Runtime entrypoints must provide refs explicitly when they are instance-scoped.
|
||||
- Keep a sanctioned bridge for Promise/callback boundaries.
|
||||
- Reduce bridge dependence on legacy `Instance.restore` / `Instance.current`.
|
||||
- Move callers toward `InstanceRef`, `WorkspaceRef`, `InstanceState`, or
|
||||
explicit context where practical.
|
||||
- Delete `project/instance.ts` only after ambient Instance coupling is gone.
|
||||
|
||||
Important distinction:
|
||||
|
||||
- `InstanceState.context`, `InstanceState.directory`, and
|
||||
`InstanceState.workspaceID` are acceptable inside normal Effect service
|
||||
code when `InstanceRef` / `WorkspaceRef` are provided by the runtime.
|
||||
- The deletion blockers are the fallback and callback paths that rely on
|
||||
ambient ALS: direct `Instance.*` reads, `InstanceState.bind(...)`,
|
||||
`AppRuntime.runPromise(...)` re-entry from plain JS, and bridge restore
|
||||
code that installs legacy ALS before invoking callbacks.
|
||||
|
||||
Current bottom-up inventory from `dev`:
|
||||
|
||||
- Direct `Instance.*` value readers:
|
||||
[`tool/repo_overview.ts`](../../src/tool/repo_overview.ts),
|
||||
[`control-plane/adapters/worktree.ts`](../../src/control-plane/adapters/worktree.ts),
|
||||
[`cli/bootstrap.ts`](../../src/cli/bootstrap.ts).
|
||||
- `InstanceState.bind(...)` callback boundaries:
|
||||
[`file/watcher.ts`](../../src/file/watcher.ts) native watcher callback,
|
||||
[`storage/db.ts`](../../src/storage/db.ts) transaction/effect callbacks,
|
||||
[`session/llm.ts`](../../src/session/llm.ts) workflow approval callback.
|
||||
- `AppRuntime.runPromise(...)` / re-entry from plain JS:
|
||||
[`project/with-instance.ts`](../../src/project/with-instance.ts),
|
||||
[`project/instance-runtime.ts`](../../src/project/instance-runtime.ts),
|
||||
[`control-plane/adapters/worktree.ts`](../../src/control-plane/adapters/worktree.ts),
|
||||
[`cli/effect-cmd.ts`](../../src/cli/effect-cmd.ts), plus global/non-instance
|
||||
callsites such as CLI upgrade and ACP agent defaults.
|
||||
- Intentional bridge users to classify, not delete blindly:
|
||||
workspace adapters in [`control-plane/workspace.ts`](../../src/control-plane/workspace.ts),
|
||||
MCP, command execution, plugins, pty lifecycle, bus scope cleanup, task
|
||||
cancellation, and HTTP lifecycle reload/dispose paths.
|
||||
- Core fallback layer to shrink last:
|
||||
[`effect/run-service.ts`](../../src/effect/run-service.ts),
|
||||
[`effect/bridge.ts`](../../src/effect/bridge.ts), and
|
||||
[`effect/instance-state.ts`](../../src/effect/instance-state.ts).
|
||||
|
||||
Recommended PR order:
|
||||
|
||||
- [ ] `INST-1` Remove direct `Instance.*` value readers. Start with
|
||||
`repo_overview`, `worktree` adapter, and `cli/bootstrap`; pass context
|
||||
explicitly or obtain it from an Effect boundary.
|
||||
- [ ] `INST-2` Move type-only `InstanceContext` imports from
|
||||
[`project/instance.ts`](../../src/project/instance.ts) to
|
||||
[`project/instance-context.ts`](../../src/project/instance-context.ts).
|
||||
- [ ] `INST-3` Audit each `InstanceState.bind(...)` callback from the inside
|
||||
out: list what the callback calls (`Bus.publish`, database effects,
|
||||
permission/session services), then replace ambient capture with explicit
|
||||
`InstanceRef` / `WorkspaceRef` provision or an `EffectBridge` call.
|
||||
- [ ] `INST-4` Classify `AppRuntime.runPromise(...)` callsites as global,
|
||||
instance-scoped with explicit refs, or bridge-required. Eliminate the
|
||||
instance-scoped callsites that rely on `run-service.attach()` falling
|
||||
back to `Instance.current`.
|
||||
- [ ] `INST-5` After consumers are explicit, remove `Instance.current` fallback
|
||||
from `InstanceState.context` and `run-service.attach()`.
|
||||
- [ ] `INST-6` Move any remaining `restore` / `bind` compatibility helpers to
|
||||
the boundary that still needs them, then delete
|
||||
[`project/instance.ts`](../../src/project/instance.ts).
|
||||
|
||||
## Lower Priority Tracks
|
||||
|
||||
|
||||
@@ -39,9 +39,10 @@ import { Filesystem } from "@/util/filesystem"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { ACPSessionManager } from "./session"
|
||||
import type { ACPConfig } from "./types"
|
||||
import { ACPRuntime } from "./runtime"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { Agent as AgentModule } from "../agent/agent"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Installation } from "@/installation"
|
||||
import { MessageV2 } from "@/session/message-v2"
|
||||
import { Config } from "@/config/config"
|
||||
@@ -1093,7 +1094,7 @@ export class Agent implements ACPAgent {
|
||||
|
||||
const currentModeId = await (async () => {
|
||||
if (!availableModes.length) return undefined
|
||||
const defaultAgent = await ACPRuntime.defaultAgentInfo(directory)
|
||||
const defaultAgent = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo()))
|
||||
const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgent.name)?.id ?? availableModes[0].id
|
||||
this.sessionManager.setMode(sessionId, resolvedModeId)
|
||||
return resolvedModeId
|
||||
@@ -1327,7 +1328,8 @@ export class Agent implements ACPAgent {
|
||||
if (!current) {
|
||||
this.sessionManager.setModel(session.id, model)
|
||||
}
|
||||
const agent = session.modeId ?? (await ACPRuntime.defaultAgentInfo(directory)).name
|
||||
const agent =
|
||||
session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo()))).name
|
||||
|
||||
const parts: Array<
|
||||
| { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { AppRuntime, type AppServices } from "@/effect/app-runtime"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { Effect } from "effect"
|
||||
|
||||
// Global ACP Effect re-entry: no project InstanceRef is provided.
|
||||
export const runGlobal = AppRuntime.runPromise
|
||||
|
||||
// Directory-scoped ACP Effect re-entry: load the project instance and provide InstanceRef.
|
||||
export async function runDirectory<A, E>(input: { directory: string; effect: Effect.Effect<A, E, AppServices> }) {
|
||||
const ctx = await InstanceRuntime.load({ directory: input.directory })
|
||||
return AppRuntime.runPromise(input.effect.pipe(Effect.provideService(InstanceRef, ctx)))
|
||||
}
|
||||
|
||||
export const defaultAgentInfo = (directory: string) =>
|
||||
runDirectory({
|
||||
directory,
|
||||
effect: Agent.Service.use((svc) => svc.defaultInfo()),
|
||||
})
|
||||
|
||||
export * as ACPRuntime from "./runtime"
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Schema } from "effect"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
|
||||
export type Definition<Type extends string = string, Properties extends Schema.Top = Schema.Top> = {
|
||||
type: Type
|
||||
@@ -18,28 +17,16 @@ export function define<Type extends string, Properties extends Schema.Top>(
|
||||
}
|
||||
|
||||
export function effectPayloads() {
|
||||
return [
|
||||
...registry
|
||||
.entries()
|
||||
.map(([type, def]) =>
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
type: Schema.Literal(type),
|
||||
properties: def.properties,
|
||||
}).annotate({ identifier: `Event.${type}` }),
|
||||
)
|
||||
.toArray(),
|
||||
...EventV2.registry
|
||||
.values()
|
||||
.map((definition) =>
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
type: Schema.Literal(definition.type),
|
||||
properties: definition.data,
|
||||
}).annotate({ identifier: `Event.${definition.type}` }),
|
||||
)
|
||||
.toArray(),
|
||||
]
|
||||
return registry
|
||||
.entries()
|
||||
.map(([type, def]) =>
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
type: Schema.Literal(type),
|
||||
properties: def.properties,
|
||||
}).annotate({ identifier: `Event.${type}` }),
|
||||
)
|
||||
.toArray()
|
||||
}
|
||||
|
||||
export * as BusEvent from "./bus-event"
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { Instance } from "../project/instance"
|
||||
import { InstanceRuntime } from "../project/instance-runtime"
|
||||
import { context } from "../project/instance-context"
|
||||
import { WithInstance } from "../project/with-instance"
|
||||
|
||||
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
|
||||
const ctx = await InstanceRuntime.load({ directory })
|
||||
try {
|
||||
return await context.provide(ctx, cb)
|
||||
} finally {
|
||||
await InstanceRuntime.disposeInstance(ctx)
|
||||
}
|
||||
return WithInstance.provide({
|
||||
directory,
|
||||
fn: async () => {
|
||||
try {
|
||||
const result = await cb()
|
||||
return result
|
||||
} finally {
|
||||
await InstanceRuntime.disposeInstance(Instance.current)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Permission } from "../../../permission"
|
||||
import { iife } from "../../../util/iife"
|
||||
import { effectCmd, fail } from "../../effect-cmd"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
import type { InstanceContext } from "@/project/instance"
|
||||
|
||||
export const AgentCommand = effectCmd({
|
||||
command: "agent <name>",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { InstanceServiceMap } from "@opencode-ai/core/instance-layer"
|
||||
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
|
||||
const Runtime = Layer.mergeAll(LocationServiceMap.layer)
|
||||
const Runtime = Layer.mergeAll(InstanceServiceMap.layer)
|
||||
|
||||
export const V2Command = effectCmd({
|
||||
command: "v2",
|
||||
@@ -37,7 +37,7 @@ export const V2Command = effectCmd({
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + EOL)
|
||||
},
|
||||
Effect.provide(
|
||||
LocationServiceMap.get({
|
||||
InstanceServiceMap.get({
|
||||
directory: process.cwd(),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
||||
|
||||
function promptOffsetWidth(value: string) {
|
||||
let width = 0
|
||||
for (const part of graphemes.segment(value)) {
|
||||
// Textarea offsets count newlines as one position; Bun.stringWidth counts them as zero.
|
||||
width += part.segment === "\n" ? 1 : Bun.stringWidth(part.segment)
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
function displayOffsetIndex(value: string, offset: number) {
|
||||
if (offset <= 0) return 0
|
||||
|
||||
let width = 0
|
||||
for (const part of graphemes.segment(value)) {
|
||||
const next = width + promptOffsetWidth(part.segment)
|
||||
const next = width + Bun.stringWidth(part.segment)
|
||||
if (next > offset) return part.index
|
||||
width = next
|
||||
}
|
||||
@@ -22,20 +13,20 @@ function displayOffsetIndex(value: string, offset: number) {
|
||||
return value.length
|
||||
}
|
||||
|
||||
export function displaySlice(value: string, start = 0, end = promptOffsetWidth(value)) {
|
||||
export function displaySlice(value: string, start = 0, end = Bun.stringWidth(value)) {
|
||||
return value.slice(displayOffsetIndex(value, start), displayOffsetIndex(value, end))
|
||||
}
|
||||
|
||||
export function displayCharAt(value: string, offset: number) {
|
||||
let width = 0
|
||||
for (const part of graphemes.segment(value)) {
|
||||
const next = width + promptOffsetWidth(part.segment)
|
||||
const next = width + Bun.stringWidth(part.segment)
|
||||
if (offset === width || offset < next) return part.segment
|
||||
width = next
|
||||
}
|
||||
}
|
||||
|
||||
export function mentionTriggerIndex(value: string, offset = promptOffsetWidth(value)) {
|
||||
export function mentionTriggerIndex(value: string, offset = Bun.stringWidth(value)) {
|
||||
const text = displaySlice(value, 0, offset)
|
||||
const index = text.lastIndexOf("@")
|
||||
if (index === -1) return
|
||||
@@ -43,6 +34,6 @@ export function mentionTriggerIndex(value: string, offset = promptOffsetWidth(va
|
||||
const before = index === 0 ? undefined : text[index - 1]
|
||||
const query = text.slice(index)
|
||||
if ((before === undefined || /\s/.test(before)) && !/\s/.test(query)) {
|
||||
return promptOffsetWidth(text.slice(0, index))
|
||||
return Bun.stringWidth(text.slice(0, index))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,8 @@ const appBindingCommands = [
|
||||
"command.palette.show",
|
||||
"session.list",
|
||||
"session.new",
|
||||
"session.cycle_recent",
|
||||
"session.cycle_recent_reverse",
|
||||
"session.quick_switch.1",
|
||||
"session.quick_switch.2",
|
||||
"session.quick_switch.3",
|
||||
@@ -479,15 +481,37 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
name: `session.quick_switch.${i + 1}`,
|
||||
title: `Switch to session in quick slot ${i + 1}`,
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
local.session.quickSwitch(i + 1)
|
||||
},
|
||||
})),
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
|
||||
? [
|
||||
{
|
||||
name: "session.cycle_recent",
|
||||
title: "Cycle to previous recent session",
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
local.session.cycleRecent(1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "session.cycle_recent_reverse",
|
||||
title: "Cycle to next recent session",
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
local.session.cycleRecent(-1)
|
||||
},
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
name: `session.quick_switch.${i + 1}`,
|
||||
title: `Switch to session in quick slot ${i + 1}`,
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
local.session.quickSwitch(i + 1)
|
||||
},
|
||||
})),
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "model.list",
|
||||
title: "Switch model",
|
||||
@@ -802,7 +826,14 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
|
||||
useBindings(() => ({
|
||||
enabled: command.matcher,
|
||||
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
|
||||
bindings: tuiConfig.keybinds.gather(
|
||||
"app",
|
||||
Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
|
||||
? appBindingCommands
|
||||
: appBindingCommands.filter(
|
||||
(c) => !c.startsWith("session.cycle_recent") && !c.startsWith("session.quick_switch"),
|
||||
),
|
||||
),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
|
||||
@@ -31,8 +31,6 @@ export function DialogSessionList() {
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
const deleteHint = useCommandShortcut("session.delete")
|
||||
const quickSwitch1 = useCommandShortcut("session.quick_switch.1")
|
||||
const quickSwitch9 = useCommandShortcut("session.quick_switch.9")
|
||||
|
||||
const [searchResults, { refetch }] = createResource(
|
||||
() => ({ query: search(), filter: sync.session.query() }),
|
||||
@@ -132,18 +130,10 @@ export function DialogSessionList() {
|
||||
|
||||
const [browseOrder] = createSignal<string[]>(orderByRecency(sync.data.session))
|
||||
|
||||
const quickSwitchHint = createMemo(() => {
|
||||
const first = quickSwitch1()
|
||||
const last = quickSwitch9()
|
||||
if (!first || !last) return undefined
|
||||
return quickSwitchRange(first, last)
|
||||
})
|
||||
const quickSwitchFooterHints = createMemo(() => {
|
||||
const hint = quickSwitchHint()
|
||||
return hint && local.session.slots().length > 0 ? [{ title: "switch", label: hint }] : []
|
||||
})
|
||||
const RECENT_LIMIT = 5
|
||||
|
||||
const options = createMemo(() => {
|
||||
const enabled = Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
|
||||
const today = new Date().toDateString()
|
||||
const sessionMap = new Map(
|
||||
sessions()
|
||||
@@ -154,9 +144,17 @@ export function DialogSessionList() {
|
||||
const searchResult = searchResults()
|
||||
const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder()
|
||||
|
||||
const pinned = local.session.pinned().filter((id) => sessionMap.has(id))
|
||||
const dismissed = enabled ? new Set(local.session.dismissedRecent()) : new Set<string>()
|
||||
const pinned = enabled ? local.session.pinned().filter((id) => sessionMap.has(id)) : []
|
||||
const pinnedSet = new Set(pinned)
|
||||
const slotByID = new Map<string, number>(local.session.slots().map((id, i) => [id, i + 1]))
|
||||
const slotByID = enabled
|
||||
? new Map<string, number>(local.session.slots().map((id, i) => [id, i + 1]))
|
||||
: new Map<string, number>()
|
||||
|
||||
const recent = enabled
|
||||
? displayOrder.filter((id) => !pinnedSet.has(id) && !dismissed.has(id)).slice(0, RECENT_LIMIT)
|
||||
: []
|
||||
const recentSet = new Set(recent)
|
||||
|
||||
function buildOption(id: string, category: string) {
|
||||
const x = sessionMap.get(id)
|
||||
@@ -200,7 +198,7 @@ export function DialogSessionList() {
|
||||
}
|
||||
|
||||
const remaining = displayOrder
|
||||
.filter((id) => !pinnedSet.has(id))
|
||||
.filter((id) => !pinnedSet.has(id) && !recentSet.has(id))
|
||||
.map((id) => {
|
||||
const x = sessionMap.get(id)
|
||||
if (!x) return undefined
|
||||
@@ -209,7 +207,11 @@ export function DialogSessionList() {
|
||||
})
|
||||
.filter((x) => x !== undefined)
|
||||
|
||||
return [...pinned.map((id) => buildOption(id, "Pinned")).filter((x) => x !== undefined), ...remaining]
|
||||
return [
|
||||
...pinned.map((id) => buildOption(id, "Pinned")).filter((x) => x !== undefined),
|
||||
...recent.map((id) => buildOption(id, "Recent")).filter((x) => x !== undefined),
|
||||
...remaining,
|
||||
]
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
@@ -234,13 +236,32 @@ export function DialogSessionList() {
|
||||
dialog.clear()
|
||||
}}
|
||||
actions={[
|
||||
{
|
||||
command: "session.pin.toggle",
|
||||
title: "pin/unpin",
|
||||
onTrigger: (option: { value: string }) => {
|
||||
local.session.togglePin(option.value)
|
||||
},
|
||||
},
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
|
||||
? [
|
||||
{
|
||||
command: "session.pin.toggle",
|
||||
title: "pin/unpin",
|
||||
onTrigger: (option: { value: string }) => {
|
||||
local.session.togglePin(option.value)
|
||||
},
|
||||
},
|
||||
{
|
||||
command: "session.toggle.recent",
|
||||
title: "toggle recent",
|
||||
onTrigger: (option: { value: string }) => {
|
||||
if (local.session.isPinned(option.value)) {
|
||||
toast.show({
|
||||
variant: "info",
|
||||
message: "Unpin the session first to toggle it in Recent",
|
||||
duration: 3000,
|
||||
})
|
||||
return
|
||||
}
|
||||
local.session.toggleRecent(option.value)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
command: "session.delete",
|
||||
title: "delete",
|
||||
@@ -297,13 +318,6 @@ export function DialogSessionList() {
|
||||
},
|
||||
},
|
||||
]}
|
||||
footerHints={quickSwitchFooterHints()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function quickSwitchRange(first: string, last: string) {
|
||||
const prefix = first.slice(0, -1)
|
||||
if (first.endsWith("1") && last === `${prefix}9`) return `${prefix}1-9`
|
||||
return `${first} through ${last}`
|
||||
}
|
||||
|
||||
@@ -87,6 +87,9 @@ export const Definitions = {
|
||||
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
|
||||
session_parent: keybind("up", "Go to parent session"),
|
||||
session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"),
|
||||
session_toggle_recent: keybind("ctrl+h", "Show or hide session in the Recent group"),
|
||||
session_cycle_recent: keybind("<leader>]", "Cycle to the previous recent session"),
|
||||
session_cycle_recent_reverse: keybind("<leader>[", "Cycle to the next recent session"),
|
||||
session_quick_switch_1: keybind("<leader>1", "Switch to session in quick slot 1"),
|
||||
session_quick_switch_2: keybind("<leader>2", "Switch to session in quick slot 2"),
|
||||
session_quick_switch_3: keybind("<leader>3", "Switch to session in quick slot 3"),
|
||||
@@ -270,6 +273,9 @@ export const CommandMap = {
|
||||
session_child_cycle_reverse: "session.child.previous",
|
||||
session_parent: "session.parent",
|
||||
session_pin_toggle: "session.pin.toggle",
|
||||
session_toggle_recent: "session.toggle.recent",
|
||||
session_cycle_recent: "session.cycle_recent",
|
||||
session_cycle_recent_reverse: "session.cycle_recent_reverse",
|
||||
session_quick_switch_1: "session.quick_switch.1",
|
||||
session_quick_switch_2: "session.quick_switch.2",
|
||||
session_quick_switch_3: "session.quick_switch.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { batch, createEffect, createMemo } from "solid-js"
|
||||
import { batch, createEffect, createMemo, on } from "solid-js"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
@@ -8,6 +8,7 @@ import { useEvent } from "@tui/context/event"
|
||||
import { uniqueBy } from "remeda"
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { iife } from "@/util/iife"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useArgs } from "./args"
|
||||
@@ -386,9 +387,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const [sessionStore, setSessionStore] = createStore<{
|
||||
ready: boolean
|
||||
pinned: string[]
|
||||
dismissedRecent: string[]
|
||||
recentOrder: string[]
|
||||
}>({
|
||||
ready: false,
|
||||
pinned: [],
|
||||
dismissedRecent: [],
|
||||
recentOrder: [],
|
||||
})
|
||||
|
||||
const filePath = path.join(Global.Path.state, "session.json")
|
||||
@@ -404,12 +409,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
state.pending = false
|
||||
void Filesystem.writeJson(filePath, {
|
||||
pinned: sessionStore.pinned,
|
||||
dismissedRecent: sessionStore.dismissedRecent,
|
||||
recentOrder: sessionStore.recentOrder,
|
||||
})
|
||||
}
|
||||
|
||||
Filesystem.readJson(filePath)
|
||||
.then((x: any) => {
|
||||
if (Array.isArray(x.pinned)) setSessionStore("pinned", x.pinned)
|
||||
if (Array.isArray(x.dismissedRecent)) setSessionStore("dismissedRecent", x.dismissedRecent)
|
||||
if (Array.isArray(x.recentOrder)) setSessionStore("recentOrder", x.recentOrder)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
@@ -419,10 +428,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
|
||||
const route = useRoute()
|
||||
const event = useEvent()
|
||||
let cycling = false
|
||||
|
||||
const slots = createMemo(() => {
|
||||
const existing = new Set(sync.data.session.filter((x) => x.parentID === undefined).map((x) => x.id))
|
||||
return sessionStore.pinned.filter((id) => existing.has(id)).slice(0, 9)
|
||||
const rootSessions = sync.data.session.filter((x) => x.parentID === undefined)
|
||||
const existing = new Set(rootSessions.map((x) => x.id))
|
||||
const dismissed = new Set(sessionStore.dismissedRecent)
|
||||
const pins = sessionStore.pinned.filter((id) => existing.has(id))
|
||||
const pinnedSet = new Set(pins)
|
||||
const recent = rootSessions
|
||||
.filter((x) => !pinnedSet.has(x.id) && !dismissed.has(x.id))
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
.map((x) => x.id)
|
||||
return [...pins, ...recent].slice(0, 9)
|
||||
})
|
||||
|
||||
function prune(sessionID: string) {
|
||||
@@ -433,6 +451,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
sessionStore.pinned.filter((x) => x !== sessionID),
|
||||
)
|
||||
}
|
||||
if (sessionStore.dismissedRecent.includes(sessionID)) {
|
||||
setSessionStore(
|
||||
"dismissedRecent",
|
||||
sessionStore.dismissedRecent.filter((x) => x !== sessionID),
|
||||
)
|
||||
}
|
||||
if (sessionStore.recentOrder.includes(sessionID)) {
|
||||
setSessionStore(
|
||||
"recentOrder",
|
||||
sessionStore.recentOrder.filter((x) => x !== sessionID),
|
||||
)
|
||||
}
|
||||
save()
|
||||
})
|
||||
}
|
||||
@@ -441,6 +471,25 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
prune(evt.properties.info.id)
|
||||
})
|
||||
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING) {
|
||||
createEffect(
|
||||
on(
|
||||
() => (sessionStore.ready && route.data.type === "session" ? route.data.sessionID : undefined),
|
||||
(sessionID) => {
|
||||
if (!sessionID) return
|
||||
if (cycling) {
|
||||
cycling = false
|
||||
return
|
||||
}
|
||||
const filtered = sessionStore.recentOrder.filter((x) => x !== sessionID)
|
||||
const next = [sessionID, ...filtered].slice(0, 20)
|
||||
setSessionStore("recentOrder", next)
|
||||
save()
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
get ready() {
|
||||
return sessionStore.ready
|
||||
@@ -448,10 +497,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
pinned() {
|
||||
return sessionStore.pinned
|
||||
},
|
||||
dismissedRecent() {
|
||||
return sessionStore.dismissedRecent
|
||||
},
|
||||
recentOrder() {
|
||||
return sessionStore.recentOrder
|
||||
},
|
||||
slots,
|
||||
isPinned(sessionID: string) {
|
||||
return sessionStore.pinned.includes(sessionID)
|
||||
},
|
||||
isDismissed(sessionID: string) {
|
||||
return sessionStore.dismissedRecent.includes(sessionID)
|
||||
},
|
||||
togglePin(sessionID: string) {
|
||||
batch(() => {
|
||||
const exists = sessionStore.pinned.includes(sessionID)
|
||||
@@ -462,12 +520,52 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
save()
|
||||
})
|
||||
},
|
||||
toggleRecent(sessionID: string) {
|
||||
batch(() => {
|
||||
const exists = sessionStore.dismissedRecent.includes(sessionID)
|
||||
const next = exists
|
||||
? sessionStore.dismissedRecent.filter((x) => x !== sessionID)
|
||||
: [sessionID, ...sessionStore.dismissedRecent]
|
||||
setSessionStore("dismissedRecent", next)
|
||||
save()
|
||||
})
|
||||
},
|
||||
quickSwitch(slot: number) {
|
||||
const target = slots()[slot - 1]
|
||||
if (!target) return
|
||||
if (route.data.type === "session" && route.data.sessionID === target) return
|
||||
route.navigate({ type: "session", sessionID: target })
|
||||
},
|
||||
cycleRecent(direction: 1 | -1) {
|
||||
if (route.data.type !== "session") {
|
||||
toast.show({
|
||||
variant: "info",
|
||||
message: "Open a session first to cycle between recent sessions",
|
||||
duration: 3000,
|
||||
})
|
||||
return
|
||||
}
|
||||
const current = route.data.sessionID
|
||||
const order = sessionStore.recentOrder.filter((id) =>
|
||||
sync.data.session.some((s) => s.id === id && s.parentID === undefined),
|
||||
)
|
||||
if (order.length < 2) {
|
||||
toast.show({
|
||||
variant: "info",
|
||||
message: "No other recent sessions to cycle to",
|
||||
duration: 3000,
|
||||
})
|
||||
return
|
||||
}
|
||||
const index = order.indexOf(current)
|
||||
if (index === -1) return
|
||||
const next = index + direction
|
||||
if (next < 0 || next >= order.length) return
|
||||
const target = order[next]
|
||||
if (!target || target === current) return
|
||||
cycling = true
|
||||
route.navigate({ type: "session", sessionID: target })
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { createMemo, type Setter } from "solid-js"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { useKV } from "./kv"
|
||||
|
||||
export type ThinkingMode = "show" | "minimal" | "hide"
|
||||
|
||||
const MODES: readonly ThinkingMode[] = ["show", "minimal", "hide"] as const
|
||||
|
||||
// OpenAI's Responses API surfaces reasoning summaries that start with a bolded
|
||||
// title line: "**Inspecting PR workflow**\n\n<body>". GitHub Copilot routes
|
||||
// through the same shape, and the opencode provider relays it too. Pull the
|
||||
// title out for a nicer label; return null for providers that don't follow
|
||||
// this convention so the caller can fall back to a generic "Thinking" string.
|
||||
export function reasoningTitle(text: string): string | null {
|
||||
const match = text.trimStart().match(/^\*\*([^*\n]+)\*\*/)
|
||||
return match ? match[1].trim() : null
|
||||
}
|
||||
|
||||
export function isThinkingMode(value: unknown): value is ThinkingMode {
|
||||
return typeof value === "string" && (MODES as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
// Cycle order matches the slash command: show → minimal → hide → show.
|
||||
export function nextThinkingMode(current: ThinkingMode): ThinkingMode {
|
||||
const idx = MODES.indexOf(current)
|
||||
return MODES[(idx + 1) % MODES.length] ?? "show"
|
||||
}
|
||||
|
||||
export function useThinkingMode() {
|
||||
const kv = useKV()
|
||||
// Capture pre-state before `kv.signal` seeds a default, so we can detect
|
||||
// first-time users with a legacy `thinking_visibility` boolean and migrate.
|
||||
// The KVProvider only renders children once kv.ready, so reads here are safe.
|
||||
const hadStored = kv.get("thinking_mode") !== undefined
|
||||
const legacy = kv.get("thinking_visibility")
|
||||
const [stored, setStored] = kv.signal<ThinkingMode>("thinking_mode", "minimal")
|
||||
|
||||
// The kv signal exposes its setter typed as `Setter<T>` which carries Solid's
|
||||
// overload set; passing an updater fn through a property access loses the
|
||||
// bivariance trick the existing `setX((prev) => ...)` callsites rely on.
|
||||
// Wrap it in a sane shape so consumers can just call `set(next)` or pass
|
||||
// an updater.
|
||||
const set = (next: ThinkingMode | ((prev: ThinkingMode) => ThinkingMode)) => {
|
||||
if (typeof next === "function") setStored(next as Setter<ThinkingMode>)
|
||||
else setStored(() => next)
|
||||
}
|
||||
|
||||
// Preserve previous experience for users who had explicitly toggled the
|
||||
// legacy `thinking_visibility` boolean. First-time users (no legacy key)
|
||||
// get the new "minimal" default.
|
||||
if (!hadStored) {
|
||||
if (legacy === true) set("show")
|
||||
else if (legacy === false) set("hide")
|
||||
}
|
||||
|
||||
const mode = createMemo<ThinkingMode>(() => {
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_MINIMAL_THINKING) return "minimal"
|
||||
const value = stored()
|
||||
return isThinkingMode(value) ? value : "minimal"
|
||||
})
|
||||
|
||||
return {
|
||||
mode,
|
||||
set,
|
||||
locked: () => Flag.OPENCODE_EXPERIMENTAL_MINIMAL_THINKING === true,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, For, type Accessor } from "solid-js"
|
||||
import { DEFAULT_THEMES, useTheme } from "@tui/context/theme"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { useCommandShortcut } from "../../keymap"
|
||||
|
||||
const themeCount = Object.keys(DEFAULT_THEMES).length
|
||||
@@ -28,6 +29,8 @@ type Shortcuts = {
|
||||
messagesToggleConceal: TipShortcut
|
||||
modelCycleRecent: TipShortcut
|
||||
modelList: TipShortcut
|
||||
sessionCycleRecent: TipShortcut
|
||||
sessionCycleRecentReverse: TipShortcut
|
||||
sessionExport: TipShortcut
|
||||
sessionInterrupt: TipShortcut
|
||||
sessionList: TipShortcut
|
||||
@@ -38,6 +41,7 @@ type Shortcuts = {
|
||||
sessionQuickSwitch9: TipShortcut
|
||||
sessionSidebarToggle: TipShortcut
|
||||
sessionTimeline: TipShortcut
|
||||
sessionToggleRecent: TipShortcut
|
||||
statusView: TipShortcut
|
||||
terminalSuspend: TipShortcut
|
||||
themeList: TipShortcut
|
||||
@@ -69,7 +73,6 @@ function parse(tip: string): TipPart[] {
|
||||
}
|
||||
|
||||
const NO_MODELS_TIP = "Run {highlight}/connect{/highlight} to add an AI provider and start coding"
|
||||
const NO_MODELS_PARTS = parse(NO_MODELS_TIP)
|
||||
|
||||
function shortcutText(value: string) {
|
||||
return `{highlight}${value}{/highlight}`
|
||||
@@ -118,6 +121,8 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
|
||||
messagesToggleConceal: configShortcut(props.api, "session.toggle.conceal"),
|
||||
modelCycleRecent: useCommandShortcut("model.cycle_recent"),
|
||||
modelList: useCommandShortcut("model.list"),
|
||||
sessionCycleRecent: useCommandShortcut("session.cycle_recent"),
|
||||
sessionCycleRecentReverse: useCommandShortcut("session.cycle_recent_reverse"),
|
||||
sessionExport: configShortcut(props.api, "session.export"),
|
||||
sessionInterrupt: configShortcut(props.api, "session.interrupt"),
|
||||
sessionList: useCommandShortcut("session.list"),
|
||||
@@ -128,6 +133,7 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
|
||||
sessionQuickSwitch9: useCommandShortcut("session.quick_switch.9"),
|
||||
sessionSidebarToggle: configShortcut(props.api, "session.sidebar.toggle"),
|
||||
sessionTimeline: configShortcut(props.api, "session.timeline"),
|
||||
sessionToggleRecent: configShortcut(props.api, "session.toggle.recent"),
|
||||
statusView: useCommandShortcut("opencode.status"),
|
||||
terminalSuspend: useCommandShortcut("terminal.suspend"),
|
||||
themeList: useCommandShortcut("theme.switch"),
|
||||
@@ -139,13 +145,8 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
|
||||
return value ? [value] : []
|
||||
})
|
||||
return tips[Math.floor(tipOffset * tips.length)] ?? NO_MODELS_TIP
|
||||
}, NO_MODELS_TIP)
|
||||
// Solid can expose a memo's initial value while a pure computation is pending.
|
||||
const parts = createMemo(() => {
|
||||
const value = tip()
|
||||
if (typeof value === "string") return parse(value)
|
||||
return NO_MODELS_PARTS
|
||||
}, NO_MODELS_PARTS)
|
||||
})
|
||||
const parts = createMemo(() => parse(tip()))
|
||||
|
||||
return (
|
||||
<box flexDirection="row" maxWidth="100%">
|
||||
@@ -175,12 +176,23 @@ const TIPS: Tip[] = [
|
||||
(shortcuts) => `Use ${commandText("/models", shortcuts.modelList())} to see and switch between available AI models`,
|
||||
(shortcuts) => `Use ${commandText("/themes", shortcuts.themeList())} to switch between ${themeCount} built-in themes`,
|
||||
(shortcuts) => `Use ${commandText("/new", shortcuts.sessionNew())} to start a fresh conversation session`,
|
||||
(shortcuts) => `Use ${commandText("/sessions", shortcuts.sessionList())} to list, pin, and continue sessions`,
|
||||
(shortcuts) => press(shortcuts.sessionPinToggle(), "in the session list to pin a session so it stays at the top"),
|
||||
(shortcuts) =>
|
||||
shortcuts.sessionQuickSwitch1() && shortcuts.sessionQuickSwitch9()
|
||||
? `Pinned sessions are assigned quick slots; use ${shortcutText(shortcuts.sessionQuickSwitch1())} through ${shortcutText(shortcuts.sessionQuickSwitch9())} to switch`
|
||||
: undefined,
|
||||
(shortcuts) => `Use ${commandText("/sessions", shortcuts.sessionList())} to list and continue previous conversations`,
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
|
||||
? ([
|
||||
(shortcuts) =>
|
||||
press(shortcuts.sessionPinToggle(), "in the session list to pin a session so it stays at the top"),
|
||||
(shortcuts) =>
|
||||
shortcuts.sessionQuickSwitch1() && shortcuts.sessionQuickSwitch9()
|
||||
? `Pinned and recent sessions are bound to ${shortcutText(shortcuts.sessionQuickSwitch1())} through ${shortcutText(shortcuts.sessionQuickSwitch9())} for one-press switching`
|
||||
: undefined,
|
||||
(shortcuts) =>
|
||||
shortcuts.sessionCycleRecent() && shortcuts.sessionCycleRecentReverse()
|
||||
? `Press ${shortcutText(shortcuts.sessionCycleRecent())} / ${shortcutText(shortcuts.sessionCycleRecentReverse())} to cycle through recently visited sessions`
|
||||
: undefined,
|
||||
(shortcuts) =>
|
||||
press(shortcuts.sessionToggleRecent(), "in the session list to show or hide a session in the Recent group"),
|
||||
] satisfies Tip[])
|
||||
: []),
|
||||
"Run {highlight}/compact{/highlight} to summarize long sessions near context limits",
|
||||
(shortcuts) => `Use ${commandText("/export", shortcuts.sessionExport())} to save the conversation as Markdown`,
|
||||
(shortcuts) => press(shortcuts.messagesCopy(), "to copy the assistant's last message to clipboard"),
|
||||
|
||||
@@ -5,7 +5,6 @@ import { SplitBorder } from "@tui/component/border"
|
||||
import { Spinner } from "@tui/component/spinner"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { useLocal } from "@tui/context/local"
|
||||
import { reasoningTitle, useThinkingMode } from "@tui/context/thinking"
|
||||
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
|
||||
import { useBindings } from "../../keymap"
|
||||
@@ -318,11 +317,7 @@ function AssistantMessage(props: {
|
||||
<AssistantText part={part as SessionMessageAssistantText} syntax={props.syntax} />
|
||||
</Match>
|
||||
<Match when={part.type === "reasoning"}>
|
||||
<AssistantReasoning
|
||||
part={part as SessionMessageAssistantReasoning}
|
||||
subtleSyntax={props.subtleSyntax}
|
||||
completedAt={() => props.message.time.completed}
|
||||
/>
|
||||
<AssistantReasoning part={part as SessionMessageAssistantReasoning} subtleSyntax={props.subtleSyntax} />
|
||||
</Match>
|
||||
<Match when={part.type === "tool"}>
|
||||
<AssistantTool part={part as SessionMessageAssistantTool} sessionID={props.sessionID} />
|
||||
@@ -383,64 +378,30 @@ function AssistantText(props: { part: SessionMessageAssistantText; syntax: Synta
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantReasoning(props: {
|
||||
part: SessionMessageAssistantReasoning
|
||||
subtleSyntax: SyntaxStyle
|
||||
completedAt: () => number | undefined
|
||||
}) {
|
||||
function AssistantReasoning(props: { part: SessionMessageAssistantReasoning; subtleSyntax: SyntaxStyle }) {
|
||||
const { theme } = useTheme()
|
||||
const thinking = useThinkingMode()
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const content = createMemo(() => props.part.text.replace("[REDACTED]", "").trim())
|
||||
const inMinimal = createMemo(() => thinking.mode() === "minimal")
|
||||
// v2 reasoning parts have no per-part `time.end` (see SessionMessageAssistantReasoning
|
||||
// in the v2 SDK); we settle on parent-message completion instead.
|
||||
const isDone = createMemo(() => props.completedAt() !== undefined)
|
||||
const title = createMemo(() => reasoningTitle(content()))
|
||||
|
||||
const toggle = () => {
|
||||
if (!inMinimal()) return
|
||||
setExpanded((prev) => !prev)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={content() && thinking.mode() !== "hide"}>
|
||||
<Switch>
|
||||
<Match when={!inMinimal() || expanded()}>
|
||||
<box
|
||||
paddingLeft={2}
|
||||
marginTop={1}
|
||||
flexDirection="column"
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.backgroundElement}
|
||||
flexShrink={0}
|
||||
onMouseUp={toggle}
|
||||
>
|
||||
<code
|
||||
filetype="markdown"
|
||||
drawUnstyledText={false}
|
||||
streaming={true}
|
||||
syntaxStyle={props.subtleSyntax}
|
||||
content={(inMinimal() ? "▼ " : "") + "_Thinking:_ " + content()}
|
||||
conceal={true}
|
||||
fg={theme.textMuted}
|
||||
/>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={isDone()}>
|
||||
<box paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
|
||||
<text fg={theme.textMuted} wrapMode="none">
|
||||
{title() ? "▶ Thought: " + title() : "▶ Thought"}
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<box paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
|
||||
<Spinner color={theme.textMuted}>{title() ? "Thinking: " + title() : "Thinking"}</Spinner>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show when={content()}>
|
||||
<box
|
||||
paddingLeft={2}
|
||||
marginTop={1}
|
||||
flexDirection="column"
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.backgroundElement}
|
||||
flexShrink={0}
|
||||
>
|
||||
<code
|
||||
filetype="markdown"
|
||||
drawUnstyledText={false}
|
||||
streaming={true}
|
||||
syntaxStyle={props.subtleSyntax}
|
||||
content={"_Thinking:_ " + content()}
|
||||
conceal={true}
|
||||
fg={theme.textMuted}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -82,7 +82,6 @@ import * as Model from "../../util/model"
|
||||
import { formatTranscript } from "../../util/transcript"
|
||||
import { UI } from "@/cli/ui.ts"
|
||||
import { useTuiConfig } from "../../context/tui-config"
|
||||
import { nextThinkingMode, reasoningTitle, useThinkingMode, type ThinkingMode } from "../../context/thinking"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
|
||||
import { DialogRetryAction } from "../../component/dialog-retry-action"
|
||||
@@ -158,7 +157,6 @@ const context = createContext<{
|
||||
width: number
|
||||
sessionID: string
|
||||
conceal: () => boolean
|
||||
thinkingMode: () => ThinkingMode
|
||||
showThinking: () => boolean
|
||||
showTimestamps: () => boolean
|
||||
showDetails: () => boolean
|
||||
@@ -216,9 +214,7 @@ export function Session() {
|
||||
const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto")
|
||||
const [sidebarOpen, setSidebarOpen] = createSignal(false)
|
||||
const [conceal, setConceal] = createSignal(true)
|
||||
const thinking = useThinkingMode()
|
||||
const thinkingMode = thinking.mode
|
||||
const showThinking = createMemo(() => thinkingMode() !== "hide")
|
||||
const [showThinking, setShowThinking] = kv.signal("thinking_visibility", true)
|
||||
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
|
||||
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
|
||||
const [showAssistantMetadata, _setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
|
||||
@@ -687,12 +683,7 @@ export function Session() {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: (() => {
|
||||
const next = nextThinkingMode(thinkingMode())
|
||||
if (next === "minimal") return "Switch thinking to minimal"
|
||||
if (next === "hide") return "Hide thinking"
|
||||
return "Show thinking"
|
||||
})(),
|
||||
title: showThinking() ? "Hide thinking" : "Show thinking",
|
||||
value: "session.toggle.thinking",
|
||||
category: "Session",
|
||||
slash: {
|
||||
@@ -700,17 +691,7 @@ export function Session() {
|
||||
aliases: ["toggle-thinking"],
|
||||
},
|
||||
run: () => {
|
||||
// Env override forces minimal for the process. Updating KV here would
|
||||
// silently diverge from what's rendered; tell the user instead.
|
||||
if (thinking.locked()) {
|
||||
toast.show({
|
||||
message: "Thinking mode is locked to minimal by OPENCODE_EXPERIMENTAL_MINIMAL_THINKING",
|
||||
variant: "info",
|
||||
})
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
thinking.set(nextThinkingMode(thinkingMode()))
|
||||
setShowThinking((prev) => !prev)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -1105,7 +1086,6 @@ export function Session() {
|
||||
},
|
||||
sessionID: route.sessionID,
|
||||
conceal,
|
||||
thinkingMode,
|
||||
showThinking,
|
||||
showTimestamps,
|
||||
showDetails,
|
||||
@@ -1512,77 +1492,32 @@ const PART_MAPPING = {
|
||||
function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
|
||||
const { theme, subtleSyntax } = useTheme()
|
||||
const ctx = use()
|
||||
// Collapsed by default in minimal mode: a single line throughout, so the
|
||||
// layout never shifts. Click to open the full markdown block, click to close.
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
|
||||
const content = createMemo(() => {
|
||||
// OpenRouter encrypts some reasoning blocks; drop the placeholder.
|
||||
// Filter out redacted reasoning chunks from OpenRouter
|
||||
// OpenRouter sends encrypted reasoning data that appears as [REDACTED]
|
||||
return props.part.text.replace("[REDACTED]", "").trim()
|
||||
})
|
||||
// Reasoning is finalized when the server sets `time.end` (see processor.ts).
|
||||
// Flips independently of the parent message completing.
|
||||
const isDone = createMemo(() => props.part.time.end !== undefined)
|
||||
const inMinimal = createMemo(() => ctx.thinkingMode() === "minimal")
|
||||
const duration = createMemo(() => {
|
||||
const end = props.part.time.end
|
||||
return end === undefined ? 0 : Math.max(0, end - props.part.time.start)
|
||||
})
|
||||
// OpenAI / Copilot / opencode-via-OpenAI emit `**Title**\n\n<body>` summary
|
||||
// blocks. Surface the title both while streaming and after settling so the
|
||||
// collapsed line carries real signal, not just a duration.
|
||||
const title = createMemo(() => reasoningTitle(content()))
|
||||
|
||||
const toggle = () => {
|
||||
if (!inMinimal()) return
|
||||
setExpanded((prev) => !prev)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={content() && ctx.thinkingMode() !== "hide"}>
|
||||
<Switch>
|
||||
<Match when={!inMinimal() || expanded()}>
|
||||
{/* Full markdown block: `show` mode, or `minimal` after the user opens it. */}
|
||||
<box
|
||||
id={"text-" + props.part.id}
|
||||
paddingLeft={2}
|
||||
marginTop={1}
|
||||
flexDirection="column"
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.backgroundElement}
|
||||
onMouseUp={toggle}
|
||||
>
|
||||
<code
|
||||
filetype="markdown"
|
||||
drawUnstyledText={false}
|
||||
streaming={true}
|
||||
syntaxStyle={subtleSyntax()}
|
||||
content={(inMinimal() ? "▼ " : "") + "_Thinking:_ " + content()}
|
||||
conceal={ctx.conceal()}
|
||||
fg={theme.textMuted}
|
||||
/>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={isDone()}>
|
||||
{/* Settled: ▶ at the start as the click-to-expand cue. */}
|
||||
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
|
||||
<text fg={theme.textMuted} wrapMode="none">
|
||||
{"▶ " +
|
||||
(title()
|
||||
? "Thought: " + title() + " · " + Locale.duration(duration())
|
||||
: "Thought for " + Locale.duration(duration()))}
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
{/* Streaming: leading animated spinner, no disclosure arrow yet — it
|
||||
snaps in once reasoning settles, signalling "done, click to expand". */}
|
||||
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
|
||||
<Spinner color={theme.textMuted}>{title() ? "Thinking: " + title() : "Thinking"}</Spinner>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show when={content() && ctx.showThinking()}>
|
||||
<box
|
||||
id={"text-" + props.part.id}
|
||||
paddingLeft={2}
|
||||
marginTop={1}
|
||||
flexDirection="column"
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.backgroundElement}
|
||||
>
|
||||
<code
|
||||
filetype="markdown"
|
||||
drawUnstyledText={false}
|
||||
streaming={true}
|
||||
syntaxStyle={subtleSyntax()}
|
||||
content={"_Thinking:_ " + content()}
|
||||
conceal={ctx.conceal()}
|
||||
fg={theme.textMuted}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -1598,7 +1533,6 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
|
||||
streaming={true}
|
||||
internalBlockMode="top-level"
|
||||
content={props.part.text.trim()}
|
||||
tableOptions={{ style: "grid" }}
|
||||
conceal={ctx.conceal()}
|
||||
fg={theme.markdownText}
|
||||
bg={theme.background}
|
||||
|
||||
@@ -38,11 +38,6 @@ export interface DialogSelectProps<T> {
|
||||
disabled?: boolean
|
||||
onTrigger: (option: DialogSelectOption<T>) => void
|
||||
}[]
|
||||
footerHints?: {
|
||||
title: string
|
||||
label: string
|
||||
side?: "left" | "right"
|
||||
}[]
|
||||
bindings?: readonly Binding<Renderable, KeyEvent>[]
|
||||
current?: T
|
||||
}
|
||||
@@ -339,12 +334,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
}
|
||||
props.ref?.(ref)
|
||||
|
||||
const visibleActions = createMemo(() => [
|
||||
...actions()
|
||||
const visibleActions = createMemo(() =>
|
||||
actions()
|
||||
.map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" }))
|
||||
.filter((item) => !item.disabled && item.label),
|
||||
...(props.footerHints ?? []),
|
||||
])
|
||||
)
|
||||
const left = createMemo(() => visibleActions().filter((item) => item.side !== "right"))
|
||||
const right = createMemo(() => visibleActions().filter((item) => item.side === "right"))
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Installation } from "@/installation"
|
||||
import { Server } from "@/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { WithInstance } from "@/project/with-instance"
|
||||
import { Rpc } from "@/util/rpc"
|
||||
import { upgrade } from "@/cli/upgrade"
|
||||
import { Config } from "@/config/config"
|
||||
@@ -76,8 +77,12 @@ export const rpc = {
|
||||
return { url: server.url.toString() }
|
||||
},
|
||||
async checkUpgrade(input: { directory: string }) {
|
||||
await InstanceRuntime.load({ directory: input.directory })
|
||||
await upgrade().catch(() => {})
|
||||
await WithInstance.provide({
|
||||
directory: input.directory,
|
||||
fn: async () => {
|
||||
await upgrade().catch(() => {})
|
||||
},
|
||||
})
|
||||
},
|
||||
async reload() {
|
||||
await AppRuntime.runPromise(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Effect, Schema } from "effect"
|
||||
import { AppRuntime, type AppServices } from "@/effect/app-runtime"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { cmd, type WithDoubleDash } from "./cmd/cmd"
|
||||
|
||||
/**
|
||||
@@ -82,11 +83,19 @@ export const effectCmd = <Args, A>(opts: EffectCmdOpts<Args, A>) =>
|
||||
return
|
||||
}
|
||||
const directory = opts.directory?.(args) ?? process.cwd()
|
||||
// Two-phase: load ctx, then run body inside Instance.current ALS.
|
||||
// Effect's InstanceRef is provided via fiber context, but that context is
|
||||
// lost across `await` inside `Effect.promise(async () => ...)` callbacks
|
||||
// — when handlers re-enter Effect via `AppRuntime.runPromise(svc.method())`
|
||||
// there, attach() falls back to Instance.current ALS, which Node preserves
|
||||
// across awaits. Matches the pre-effectCmd `bootstrap()` behavior.
|
||||
const { store, ctx } = await AppRuntime.runPromise(
|
||||
InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))),
|
||||
)
|
||||
try {
|
||||
await AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx)))
|
||||
await Instance.restore(ctx, () =>
|
||||
AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx))),
|
||||
)
|
||||
} finally {
|
||||
await AppRuntime.runPromise(store.dispose(ctx))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
import type { InstanceContext } from "@/project/instance"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { Config } from "@/config/config"
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Auth } from "../auth"
|
||||
import { Env } from "../env"
|
||||
import { applyEdits, modify } from "jsonc-parser"
|
||||
import { type InstanceContext } from "../project/instance"
|
||||
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { existsSync } from "fs"
|
||||
import { Account } from "@/account/account"
|
||||
@@ -19,7 +20,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { containsPath, type InstanceContext } from "../project/instance-context"
|
||||
import { containsPath } from "../project/instance-context"
|
||||
import { NonNegativeInt, PositiveInt, type DeepMutable } from "@opencode-ai/core/schema"
|
||||
import { ConfigAgent } from "./agent"
|
||||
import { ConfigAttachment } from "./attachment"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { type WorkspaceAdapter, type WorkspaceAdapterContext, WorkspaceInfo } from "../types"
|
||||
import { Schema } from "effect"
|
||||
import { type WorkspaceAdapter, WorkspaceInfo } from "../types"
|
||||
|
||||
const WorktreeConfig = Schema.Struct({
|
||||
name: WorkspaceInfo.fields.name,
|
||||
@@ -10,81 +9,53 @@ const WorktreeConfig = Schema.Struct({
|
||||
const decodeWorktreeConfig = Schema.decodeUnknownSync(WorktreeConfig)
|
||||
|
||||
async function loadWorktree() {
|
||||
const [{ AppRuntime }, { Worktree }] = await Promise.all([import("@/effect/app-runtime"), import("@/worktree")])
|
||||
return { AppRuntime, Worktree }
|
||||
const [{ AppRuntime }, { Instance }, { Worktree }] = await Promise.all([
|
||||
import("@/effect/app-runtime"),
|
||||
import("@/project/instance"),
|
||||
import("@/worktree"),
|
||||
])
|
||||
return { AppRuntime, Instance, Worktree }
|
||||
}
|
||||
|
||||
function requireInstance(context: WorkspaceAdapterContext | undefined) {
|
||||
if (!context?.instance) throw new Error("Worktree adapter requires an instance context")
|
||||
return context.instance
|
||||
}
|
||||
|
||||
const provideContext = <A, E, R>(effect: Effect.Effect<A, E, R>, context: WorkspaceAdapterContext | undefined) =>
|
||||
effect.pipe(
|
||||
Effect.provideService(InstanceRef, requireInstance(context)),
|
||||
Effect.provideService(WorkspaceRef, context?.workspaceID),
|
||||
)
|
||||
|
||||
export const WorktreeAdapter: WorkspaceAdapter = {
|
||||
name: "Worktree",
|
||||
description: "Create a git worktree",
|
||||
async configure(info, context) {
|
||||
async configure(info) {
|
||||
const { AppRuntime, Worktree } = await loadWorktree()
|
||||
const next = await AppRuntime.runPromise(
|
||||
provideContext(
|
||||
Worktree.Service.use((svc) => svc.makeWorktreeInfo({ detached: true })),
|
||||
context,
|
||||
),
|
||||
)
|
||||
const next = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo({ detached: true })))
|
||||
return {
|
||||
...info,
|
||||
name: next.name,
|
||||
directory: next.directory,
|
||||
}
|
||||
},
|
||||
async create(info, _env, _from, context) {
|
||||
async create(info) {
|
||||
const { AppRuntime, Worktree } = await loadWorktree()
|
||||
const config = decodeWorktreeConfig(info)
|
||||
await AppRuntime.runPromise(
|
||||
provideContext(
|
||||
Worktree.Service.use((svc) =>
|
||||
svc.createFromInfo({
|
||||
name: config.name,
|
||||
directory: config.directory,
|
||||
...(config.branch ? { branch: config.branch } : {}),
|
||||
}),
|
||||
),
|
||||
context,
|
||||
Worktree.Service.use((svc) =>
|
||||
svc.createFromInfo({
|
||||
name: config.name,
|
||||
directory: config.directory,
|
||||
...(config.branch ? { branch: config.branch } : {}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
async list(context) {
|
||||
const { AppRuntime, Worktree } = await loadWorktree()
|
||||
const ctx = requireInstance(context)
|
||||
return (
|
||||
await AppRuntime.runPromise(
|
||||
provideContext(
|
||||
Worktree.Service.use((svc) => svc.list()),
|
||||
context,
|
||||
),
|
||||
)
|
||||
).map((info) => ({
|
||||
async list() {
|
||||
const { AppRuntime, Instance, Worktree } = await loadWorktree()
|
||||
return (await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.list()))).map((info) => ({
|
||||
type: "worktree",
|
||||
name: info.name,
|
||||
branch: info.branch,
|
||||
directory: info.directory,
|
||||
projectID: ctx.project.id,
|
||||
projectID: Instance.project.id,
|
||||
}))
|
||||
},
|
||||
async remove(info, context) {
|
||||
async remove(info) {
|
||||
const { AppRuntime, Worktree } = await loadWorktree()
|
||||
const config = decodeWorktreeConfig(info)
|
||||
await AppRuntime.runPromise(
|
||||
provideContext(
|
||||
Worktree.Service.use((svc) => svc.remove({ directory: config.directory })),
|
||||
context,
|
||||
),
|
||||
)
|
||||
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove({ directory: config.directory })))
|
||||
},
|
||||
target(info) {
|
||||
const config = decodeWorktreeConfig(info)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Schema, Struct } from "effect"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
import { WorkspaceID } from "./schema"
|
||||
import type { DeepMutable } from "@opencode-ai/core/schema"
|
||||
|
||||
@@ -38,22 +37,12 @@ export type Target =
|
||||
headers?: HeadersInit
|
||||
}
|
||||
|
||||
export type WorkspaceAdapterContext = {
|
||||
readonly instance?: InstanceContext
|
||||
readonly workspaceID?: WorkspaceID
|
||||
}
|
||||
|
||||
export type WorkspaceAdapter = {
|
||||
name: string
|
||||
description: string
|
||||
configure(info: WorkspaceInfo, context?: WorkspaceAdapterContext): WorkspaceInfo | Promise<WorkspaceInfo>
|
||||
create(
|
||||
info: WorkspaceInfo,
|
||||
env: Record<string, string | undefined>,
|
||||
from?: WorkspaceInfo,
|
||||
context?: WorkspaceAdapterContext,
|
||||
): Promise<void>
|
||||
list?(context?: WorkspaceAdapterContext): WorkspaceListedInfo[] | Promise<WorkspaceListedInfo[]>
|
||||
remove(info: WorkspaceInfo, context?: WorkspaceAdapterContext): Promise<void>
|
||||
target(info: WorkspaceInfo, context?: WorkspaceAdapterContext): Target | Promise<Target>
|
||||
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
|
||||
create(info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo): Promise<void>
|
||||
list?(): WorkspaceListedInfo[] | Promise<WorkspaceListedInfo[]>
|
||||
remove(info: WorkspaceInfo): Promise<void>
|
||||
target(info: WorkspaceInfo): Target | Promise<Target>
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { getAdapter } from "./adapters"
|
||||
import type { WorkspaceAdapter, WorkspaceInfo } from "./types"
|
||||
|
||||
const context = Effect.gen(function* () {
|
||||
return {
|
||||
instance: yield* InstanceRef,
|
||||
workspaceID: yield* WorkspaceRef,
|
||||
}
|
||||
})
|
||||
|
||||
export const target = (info: WorkspaceInfo) =>
|
||||
Effect.gen(function* () {
|
||||
const adapter = getAdapter(info.projectID, info.type)
|
||||
const ctx = yield* context
|
||||
return yield* EffectBridge.fromPromise(() => adapter.target(info, ctx))
|
||||
})
|
||||
|
||||
export const configure = (adapter: WorkspaceAdapter, info: WorkspaceInfo) =>
|
||||
Effect.gen(function* () {
|
||||
const ctx = yield* context
|
||||
return yield* EffectBridge.fromPromise(() => adapter.configure(info, ctx))
|
||||
})
|
||||
|
||||
export const create = (
|
||||
adapter: WorkspaceAdapter,
|
||||
info: WorkspaceInfo,
|
||||
env: Record<string, string | undefined>,
|
||||
from?: WorkspaceInfo,
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const ctx = yield* context
|
||||
return yield* EffectBridge.fromPromise(() => adapter.create(info, env, from, ctx))
|
||||
})
|
||||
|
||||
export const list = (adapter: WorkspaceAdapter) =>
|
||||
Effect.gen(function* () {
|
||||
const ctx = yield* context
|
||||
return yield* EffectBridge.fromPromise(() => Promise.resolve(adapter.list?.(ctx) ?? []))
|
||||
})
|
||||
|
||||
export const remove = (info: WorkspaceInfo) =>
|
||||
Effect.gen(function* () {
|
||||
const adapter = getAdapter(info.projectID, info.type)
|
||||
const ctx = yield* context
|
||||
return yield* EffectBridge.fromPromise(() => adapter.remove(info, ctx))
|
||||
})
|
||||
|
||||
export * as WorkspaceAdapterRuntime from "./workspace-adapter-runtime"
|
||||
@@ -26,11 +26,11 @@ import { SessionID } from "@/session/schema"
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
import { errorData } from "@/util/error"
|
||||
import { waitEvent } from "./util"
|
||||
import { WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { WorkspaceContext } from "./workspace-context"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { WorkspaceAdapterRuntime } from "./workspace-adapter-runtime"
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
...WorkspaceInfoSchema.fields,
|
||||
@@ -281,7 +281,8 @@ export const layer = Layer.effect(
|
||||
const workspace = yield* get(input.workspaceID)
|
||||
if (!workspace) return input.fallback
|
||||
|
||||
const target = yield* WorkspaceAdapterRuntime.target(workspace)
|
||||
const adapter = getAdapter(workspace.projectID, workspace.type)
|
||||
const target = yield* EffectBridge.fromPromise(() => adapter.target(workspace))
|
||||
|
||||
if (target.type === "local") {
|
||||
const store = yield* InstanceStore.Service
|
||||
@@ -374,27 +375,35 @@ export const layer = Layer.effect(
|
||||
events: events.length,
|
||||
})
|
||||
|
||||
yield* Effect.forEach(
|
||||
events,
|
||||
(event) =>
|
||||
sync
|
||||
.replay(
|
||||
{
|
||||
id: event.id,
|
||||
aggregateID: event.aggregate_id,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
data: event.data,
|
||||
},
|
||||
{ publish: true },
|
||||
yield* Effect.promise(async () => {
|
||||
await WorkspaceContext.provide({
|
||||
workspaceID: space.id,
|
||||
async fn() {
|
||||
await Effect.runPromise(
|
||||
Effect.forEach(
|
||||
events,
|
||||
(event) =>
|
||||
sync.replay(
|
||||
{
|
||||
id: event.id,
|
||||
aggregateID: event.aggregate_id,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
data: event.data,
|
||||
},
|
||||
{ publish: true },
|
||||
),
|
||||
{ discard: true },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.provideService(WorkspaceRef, space.id)),
|
||||
{ discard: true },
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const syncWorkspaceLoop = Effect.fn("Workspace.syncWorkspaceLoop")(function* (space: Info) {
|
||||
const target = yield* WorkspaceAdapterRuntime.target(space)
|
||||
const adapter = getAdapter(space.projectID, space.type)
|
||||
const target = yield* EffectBridge.fromPromise(() => adapter.target(space))
|
||||
|
||||
if (target.type === "local") return
|
||||
|
||||
@@ -477,7 +486,8 @@ export const layer = Layer.effect(
|
||||
const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) {
|
||||
if (!flags.experimentalWorkspaces) return
|
||||
|
||||
const target = yield* WorkspaceAdapterRuntime.target(space).pipe(
|
||||
const adapter = getAdapter(space.projectID, space.type)
|
||||
const target = yield* EffectBridge.fromPromise(() => adapter.target(space)).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
setStatus(space.id, "error")
|
||||
@@ -528,13 +538,15 @@ export const layer = Layer.effect(
|
||||
const create = Effect.fn("Workspace.create")(function* (input: CreateInput) {
|
||||
const id = WorkspaceID.ascending(input.id)
|
||||
const adapter = getAdapter(input.projectID, input.type)
|
||||
const config = yield* WorkspaceAdapterRuntime.configure(adapter, {
|
||||
...input,
|
||||
id,
|
||||
name: Slug.create(),
|
||||
directory: null,
|
||||
extra: input.extra ?? null,
|
||||
})
|
||||
const config = yield* EffectBridge.fromPromise(() =>
|
||||
adapter.configure({
|
||||
...input,
|
||||
id,
|
||||
name: Slug.create(),
|
||||
directory: null,
|
||||
extra: input.extra ?? null,
|
||||
}),
|
||||
)
|
||||
|
||||
const info: Info = {
|
||||
id,
|
||||
@@ -571,7 +583,7 @@ export const layer = Layer.effect(
|
||||
OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES,
|
||||
}
|
||||
|
||||
yield* WorkspaceAdapterRuntime.create(adapter, config, env)
|
||||
yield* EffectBridge.fromPromise(() => adapter.create(config, env))
|
||||
yield* Effect.all(
|
||||
[
|
||||
waitEvent({
|
||||
@@ -610,7 +622,8 @@ export const layer = Layer.effect(
|
||||
if (current?.workspaceID) {
|
||||
const previous = yield* get(current.workspaceID)
|
||||
if (previous) {
|
||||
const target = yield* WorkspaceAdapterRuntime.target(previous)
|
||||
const adapter = getAdapter(previous.projectID, previous.type)
|
||||
const target = yield* EffectBridge.fromPromise(() => adapter.target(previous))
|
||||
|
||||
if (target.type === "remote") {
|
||||
yield* syncHistory(previous, target.url, target.headers).pipe(
|
||||
@@ -688,7 +701,8 @@ export const layer = Layer.effect(
|
||||
workspaceID,
|
||||
})
|
||||
|
||||
const target = yield* WorkspaceAdapterRuntime.target(space)
|
||||
const adapter = getAdapter(space.projectID, space.type)
|
||||
const target = yield* EffectBridge.fromPromise(() => adapter.target(space))
|
||||
|
||||
if (target.type === "local") {
|
||||
yield* sync.run(Session.Event.Updated, {
|
||||
@@ -841,14 +855,16 @@ export const layer = Layer.effect(
|
||||
const discovered = yield* Effect.forEach(
|
||||
registeredAdapters(project.id),
|
||||
([type, adapter]) =>
|
||||
WorkspaceAdapterRuntime.list(adapter).pipe(
|
||||
Effect.catchCause((error) =>
|
||||
Effect.sync(() => {
|
||||
log.warn("workspace adapter list failed", { type, error })
|
||||
return []
|
||||
}),
|
||||
),
|
||||
),
|
||||
adapter.list
|
||||
? EffectBridge.fromPromise(() => Promise.resolve(adapter.list?.() ?? [])).pipe(
|
||||
Effect.catchCause((error) =>
|
||||
Effect.sync(() => {
|
||||
log.warn("workspace adapter list failed", { type, error })
|
||||
return []
|
||||
}),
|
||||
),
|
||||
)
|
||||
: Effect.succeed([]),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.map((items) => items.flat()))
|
||||
|
||||
@@ -921,7 +937,8 @@ export const layer = Layer.effect(
|
||||
const info = fromRow(row)
|
||||
yield* Effect.catchCause(
|
||||
Effect.gen(function* () {
|
||||
yield* WorkspaceAdapterRuntime.remove(info)
|
||||
const adapter = getAdapter(info.projectID, row.type)
|
||||
yield* EffectBridge.fromPromise(() => adapter.remove(info))
|
||||
}),
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
|
||||
@@ -56,7 +56,6 @@ import { Npm } from "@opencode-ai/core/npm"
|
||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
import { DataMigration } from "@/data-migration"
|
||||
import { BackgroundJob } from "@/background/job"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
|
||||
export const AppLayer = Layer.mergeAll(
|
||||
@@ -112,7 +111,6 @@ export const AppLayer = Layer.mergeAll(
|
||||
ShareNext.defaultLayer,
|
||||
SessionShare.defaultLayer,
|
||||
SyncEvent.defaultLayer,
|
||||
EventV2Bridge.defaultLayer,
|
||||
DataMigration.defaultLayer,
|
||||
).pipe(Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer))
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Context, Effect, Exit, Fiber } from "effect"
|
||||
import { Effect, Exit, Fiber } from "effect"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { Instance, type InstanceContext } from "@/project/instance"
|
||||
import type { WorkspaceID } from "@/control-plane/schema"
|
||||
import { LocalContext } from "@/util/local-context"
|
||||
import { InstanceRef, WorkspaceRef } from "./instance-ref"
|
||||
import { attachWith } from "./run-service"
|
||||
|
||||
@@ -8,75 +10,67 @@ export interface Shape {
|
||||
readonly promise: <A, E, R>(effect: Effect.Effect<A, E, R>) => Promise<A>
|
||||
readonly fork: <A, E, R>(effect: Effect.Effect<A, E, R>) => Fiber.Fiber<A, E>
|
||||
readonly run: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E>
|
||||
readonly bind: <Args extends readonly unknown[], Result>(fn: (...args: Args) => Result) => (...args: Args) => Result
|
||||
}
|
||||
|
||||
function restoreWorkspace<R>(workspace: WorkspaceID | undefined, fn: () => R): R {
|
||||
function restore<R>(instance: InstanceContext | undefined, workspace: WorkspaceID | undefined, fn: () => R): R {
|
||||
if (instance && workspace !== undefined) {
|
||||
return WorkspaceContext.restore(workspace, () => Instance.restore(instance, fn))
|
||||
}
|
||||
if (instance) return Instance.restore(instance, fn)
|
||||
if (workspace !== undefined) return WorkspaceContext.restore(workspace, fn)
|
||||
return fn()
|
||||
}
|
||||
|
||||
function captureSync() {
|
||||
const fiber = Fiber.getCurrent()
|
||||
const instance = fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined
|
||||
const workspace =
|
||||
(fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined) ?? WorkspaceContext.workspaceID
|
||||
return { instance, workspace }
|
||||
}
|
||||
|
||||
export const bind = <Args extends readonly unknown[], Result>(fn: (...args: Args) => Result) => {
|
||||
const captured = captureSync()
|
||||
return (...args: Args) =>
|
||||
restoreWorkspace(captured.workspace, () =>
|
||||
Effect.runSync(
|
||||
attachWith(
|
||||
Effect.sync(() => fn(...args)),
|
||||
captured,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge from Effect into a Promise-returning JS callback while preserving
|
||||
* `WorkspaceContext` AsyncLocalStorage for callback code that still reads it.
|
||||
* `InstanceRef` is captured for effects run through the returned bridge APIs;
|
||||
* plain JS callbacks that need it should receive the ref explicitly.
|
||||
* Bridge from Effect into a Promise-returning JS callback while installing
|
||||
* legacy `Instance.context` and `WorkspaceContext` AsyncLocalStorage for
|
||||
* the duration of the callback. Effect's `InstanceRef`/`WorkspaceRef` do
|
||||
* not propagate across async/await boundaries inside `Effect.promise(() =>
|
||||
* async fn)` callbacks that re-enter Effect via `AppRuntime.runPromise`,
|
||||
* but Node's AsyncLocalStorage does. Use this whenever an Effect crosses
|
||||
* into JS that may itself spawn new Effect runtimes (workspace adapters,
|
||||
* legacy plugins, etc.).
|
||||
*
|
||||
* Mirrors `Effect.promise` but restores workspace ALS first.
|
||||
* Mirrors `Effect.promise` but restores legacy ALS first.
|
||||
*/
|
||||
export const fromPromise = <T>(fn: () => Promise<T> | T): Effect.Effect<T> =>
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceRef
|
||||
const workspace = yield* WorkspaceRef
|
||||
return yield* Effect.promise(() => Promise.resolve(restoreWorkspace(workspace, () => fn())))
|
||||
return yield* Effect.promise(() => Promise.resolve(restore(instance, workspace, () => fn())))
|
||||
})
|
||||
|
||||
export function make(): Effect.Effect<Shape> {
|
||||
return Effect.gen(function* () {
|
||||
const ctx = yield* Effect.context()
|
||||
const captured = captureSync()
|
||||
const instance = (yield* InstanceRef) ?? captured.instance
|
||||
const workspace = (yield* WorkspaceRef) ?? captured.workspace
|
||||
const value = yield* InstanceRef
|
||||
const instance =
|
||||
value ??
|
||||
(() => {
|
||||
try {
|
||||
return Instance.current
|
||||
} catch (err) {
|
||||
if (!(err instanceof LocalContext.NotFound)) throw err
|
||||
}
|
||||
})()
|
||||
const workspace = (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID
|
||||
const attach = <A, E, R>(effect: Effect.Effect<A, E, R>) => attachWith(effect, { instance, workspace })
|
||||
const wrap = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
attachWith(effect.pipe(Effect.provide(ctx)) as Effect.Effect<A, E, never>, { instance, workspace })
|
||||
attach(effect).pipe(Effect.provide(ctx)) as Effect.Effect<A, E, never>
|
||||
|
||||
return {
|
||||
promise: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
restoreWorkspace(workspace, () => Effect.runPromise(wrap(effect))),
|
||||
restore(instance, workspace, () => Effect.runPromise(wrap(effect))),
|
||||
fork: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
restoreWorkspace(workspace, () => Effect.runFork(wrap(effect))),
|
||||
restore(instance, workspace, () => Effect.runFork(wrap(effect))),
|
||||
run: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.callback<A, E>((resume) => {
|
||||
restoreWorkspace(workspace, () =>
|
||||
restore(instance, workspace, () =>
|
||||
Effect.runPromiseExit(wrap(effect)).then((exit) =>
|
||||
resume(Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
bind:
|
||||
<Args extends readonly unknown[], Result>(fn: (...args: Args) => Result) =>
|
||||
(...args: Args) =>
|
||||
restoreWorkspace(workspace, () => Effect.runSync(wrap(Effect.sync(() => fn(...args))))),
|
||||
} satisfies Shape
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Context } from "effect"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
import type { InstanceContext } from "@/project/instance"
|
||||
import type { WorkspaceID } from "@/control-plane/schema"
|
||||
|
||||
export const InstanceRef = Context.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Effect, ScopedCache, Scope } from "effect"
|
||||
import { Effect, Fiber, ScopedCache, Scope, Context } from "effect"
|
||||
import * as EffectLogger from "@opencode-ai/core/effect/logger"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
import { Instance, type InstanceContext } from "@/project/instance"
|
||||
import { LocalContext } from "@/util/local-context"
|
||||
import { InstanceRef, WorkspaceRef } from "./instance-ref"
|
||||
import { registerDisposer } from "./instance-registry"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
@@ -12,10 +13,20 @@ export interface InstanceState<A, E = never, R = never> {
|
||||
readonly cache: ScopedCache.ScopedCache<string, A, E, R>
|
||||
}
|
||||
|
||||
export const bind = <F extends (...args: any[]) => any>(fn: F): F => {
|
||||
try {
|
||||
return Instance.bind(fn)
|
||||
} catch (err) {
|
||||
if (!(err instanceof LocalContext.NotFound)) throw err
|
||||
}
|
||||
const fiber = Fiber.getCurrent()
|
||||
const ctx = fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined
|
||||
if (!ctx) return fn
|
||||
return ((...args: any[]) => Instance.restore(ctx, () => fn(...args))) as F
|
||||
}
|
||||
|
||||
export const context = Effect.gen(function* () {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return yield* Effect.die(new Error("InstanceRef not provided"))
|
||||
return ctx
|
||||
return (yield* InstanceRef) ?? Instance.current
|
||||
})
|
||||
|
||||
export const workspaceID = Effect.gen(function* () {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Effect, Fiber, Layer, ManagedRuntime } from "effect"
|
||||
import * as Context from "effect/Context"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { LocalContext } from "@/util/local-context"
|
||||
import { InstanceRef, WorkspaceRef } from "./instance-ref"
|
||||
import * as Observability from "@opencode-ai/core/effect/observability"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
import type { InstanceContext } from "@/project/instance"
|
||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
|
||||
type Refs = {
|
||||
@@ -23,9 +25,17 @@ export function attachWith<A, E, R>(effect: Effect.Effect<A, E, R>, refs: Refs):
|
||||
|
||||
export function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
|
||||
const workspace = WorkspaceContext.workspaceID
|
||||
const instance = (() => {
|
||||
try {
|
||||
return Instance.current
|
||||
} catch (err) {
|
||||
if (!(err instanceof LocalContext.NotFound)) throw err
|
||||
}
|
||||
})()
|
||||
if (instance && workspace !== undefined) return attachWith(effect, { instance, workspace })
|
||||
const fiber = Fiber.getCurrent()
|
||||
return attachWith(effect, {
|
||||
instance: fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined,
|
||||
instance: instance ?? (fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined),
|
||||
workspace: workspace ?? (fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,13 +17,6 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
|
||||
disableDefaultPlugins: bool("OPENCODE_DISABLE_DEFAULT_PLUGINS"),
|
||||
disableChannelDb: bool("OPENCODE_DISABLE_CHANNEL_DB"),
|
||||
disableEmbeddedWebUi: bool("OPENCODE_DISABLE_EMBEDDED_WEB_UI"),
|
||||
disableExternalSkills: bool("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
|
||||
disableLspDownload: bool("OPENCODE_DISABLE_LSP_DOWNLOAD"),
|
||||
skipMigrations: bool("OPENCODE_SKIP_MIGRATIONS"),
|
||||
disableClaudeCodePrompt: Config.all({
|
||||
broad: bool("OPENCODE_DISABLE_CLAUDE_CODE"),
|
||||
direct: bool("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
|
||||
}).pipe(Config.map((flags) => flags.broad || flags.direct)),
|
||||
disableClaudeCodeSkills: Config.all({
|
||||
broad: bool("OPENCODE_DISABLE_CLAUDE_CODE"),
|
||||
direct: bool("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS"),
|
||||
@@ -48,7 +41,6 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
|
||||
experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
|
||||
experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
|
||||
experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
|
||||
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
|
||||
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
|
||||
client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
|
||||
}) {}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
// Temporary V2 bridge: core events are the publish path, but the rest of
|
||||
// opencode and the HTTP event stream still expect legacy bus/sync payloads.
|
||||
// This layer goes away once consumers subscribe to core EventV2 directly.
|
||||
import { Bus as ProjectBus } from "@/bus"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import "@opencode-ai/core/catalog"
|
||||
import "@opencode-ai/core/session-event"
|
||||
import { Context, Effect, Layer, Option } from "effect"
|
||||
|
||||
export function toSyncDefinition<D extends EventV2.Definition>(definition: D) {
|
||||
const result = {
|
||||
type: definition.type,
|
||||
version: definition.version,
|
||||
aggregate: definition.aggregate,
|
||||
schema: definition.data,
|
||||
properties: definition.data,
|
||||
}
|
||||
return result as SyncEvent.Definition<D["type"], D["data"], D["data"]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, EventV2.Interface>()("@opencode/EventV2Bridge") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const bus = yield* ProjectBus.Service
|
||||
const sync = yield* SyncEvent.Service
|
||||
|
||||
const publishGlobal = (event: EventV2.Payload) =>
|
||||
Effect.sync(() => {
|
||||
GlobalBus.emit("event", {
|
||||
workspace: event.location?.workspaceID,
|
||||
payload: {
|
||||
id: event.id,
|
||||
type: event.type,
|
||||
properties: event.data,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const provideEventLocation = <E, R>(event: EventV2.Payload, effect: Effect.Effect<void, E, R>) => {
|
||||
return Effect.gen(function* () {
|
||||
const ctx = yield* InstanceRef
|
||||
if (ctx) return yield* effect
|
||||
const store = Option.getOrUndefined(yield* Effect.serviceOption(InstanceStore.Service))
|
||||
if (!event.location?.directory || !store) return yield* publishGlobal(event)
|
||||
return yield* store.load({ directory: event.location.directory }).pipe(
|
||||
Effect.flatMap((ctx) => {
|
||||
const withInstance = effect.pipe(Effect.provideService(InstanceRef, ctx))
|
||||
if (!event.location?.workspaceID) return withInstance
|
||||
return withInstance.pipe(Effect.provideService(WorkspaceRef, event.location.workspaceID))
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const unsubscribe = yield* events.sync((event) => {
|
||||
const definition = EventV2.registry.get(event.type)
|
||||
if (!definition) return Effect.void
|
||||
const aggregateID = definition.aggregate
|
||||
? (event.data as Record<string, unknown>)[definition.aggregate]
|
||||
: undefined
|
||||
|
||||
if (definition.version !== undefined && typeof aggregateID === "string") {
|
||||
return provideEventLocation(event, sync.run(toSyncDefinition(definition), event.data))
|
||||
}
|
||||
|
||||
return provideEventLocation(
|
||||
event,
|
||||
bus.publish({ type: definition.type, properties: definition.data }, event.data, { id: event.id }),
|
||||
)
|
||||
})
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
return Service.of(events)
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provide(SyncEvent.defaultLayer),
|
||||
Layer.provide(ProjectBus.defaultLayer),
|
||||
)
|
||||
|
||||
export * as EventV2Bridge from "./event-v2-bridge"
|
||||
@@ -2,11 +2,10 @@ import { Cause, Effect, Layer, Context, Schema } from "effect"
|
||||
// @ts-ignore
|
||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
import type ParcelWatcher from "@parcel/watcher"
|
||||
import { readdir, realpath } from "fs/promises"
|
||||
import { readdir } from "fs/promises"
|
||||
import path from "path"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Git } from "@/git"
|
||||
@@ -89,13 +88,13 @@ export const layer = Layer.effect(
|
||||
if (!w) return
|
||||
|
||||
log.info("watcher backend", { directory: ctx.directory, platform: process.platform, backend })
|
||||
const bridge = yield* EffectBridge.make()
|
||||
|
||||
const subs: ParcelWatcher.AsyncSubscription[] = []
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
|
||||
)
|
||||
|
||||
const cb: ParcelWatcher.SubscribeCallback = bridge.bind((err, evts) => {
|
||||
const cb: ParcelWatcher.SubscribeCallback = InstanceState.bind((err, evts) => {
|
||||
if (err) return
|
||||
for (const evt of evts) {
|
||||
if (evt.type === "create") void Bus.publish(Event.Updated, { file: evt.path, event: "add" })
|
||||
@@ -132,14 +131,8 @@ export const layer = Layer.effect(
|
||||
const result = yield* git.run(["rev-parse", "--git-dir"], {
|
||||
cwd: ctx.worktree,
|
||||
})
|
||||
const resolved = result.exitCode === 0 ? path.resolve(ctx.worktree, result.text().trim()) : undefined
|
||||
const vcsDir = resolved ? yield* Effect.promise(() => realpath(resolved).catch(() => resolved)) : undefined
|
||||
if (
|
||||
vcsDir &&
|
||||
!cfgIgnores.includes(".git") &&
|
||||
!cfgIgnores.includes(vcsDir) &&
|
||||
(!resolved || !cfgIgnores.includes(resolved))
|
||||
) {
|
||||
const vcsDir = result.exitCode === 0 ? path.resolve(ctx.worktree, result.text().trim()) : undefined
|
||||
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
|
||||
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
|
||||
(entry) => entry !== "HEAD",
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import type { InstanceContext } from "../project/instance-context"
|
||||
import type { InstanceContext } from "../project/instance"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Process } from "@/util/process"
|
||||
import { which } from "../util/which"
|
||||
|
||||
@@ -7,13 +7,10 @@ import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Process } from "@/util/process"
|
||||
import { LANGUAGE_EXTENSIONS } from "./language"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Schema } from "effect"
|
||||
import type * as LSPServer from "./server"
|
||||
import { withTimeout } from "../util/timeout"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
|
||||
const DIAGNOSTICS_DEBOUNCE_MS = 150
|
||||
const DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS = 5_000
|
||||
@@ -28,7 +25,6 @@ const FILE_CHANGE_CHANGED = 2
|
||||
const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2
|
||||
|
||||
const log = Log.create({ service: "lsp.client" })
|
||||
const busRuntime = makeRuntime(Bus.Service, Bus.layer)
|
||||
|
||||
export type Info = NonNullable<Awaited<ReturnType<typeof create>>>
|
||||
|
||||
@@ -138,16 +134,9 @@ function shouldSeedDiagnosticsOnFirstPush(serverID: string) {
|
||||
return serverID === "typescript"
|
||||
}
|
||||
|
||||
export async function create(input: {
|
||||
serverID: string
|
||||
server: LSPServer.Handle
|
||||
root: string
|
||||
directory: string
|
||||
instance: InstanceContext
|
||||
}) {
|
||||
export async function create(input: { serverID: string; server: LSPServer.Handle; root: string; directory: string }) {
|
||||
const logger = log.clone().tag("serverID", input.serverID)
|
||||
logger.info("starting client")
|
||||
const instance = input.instance
|
||||
|
||||
const connection = createMessageConnection(
|
||||
new StreamMessageReader(input.server.process.stdout as any),
|
||||
@@ -173,11 +162,7 @@ export async function create(input: {
|
||||
dedupeDiagnostics([...(pushDiagnostics.get(filePath) ?? []), ...(pullDiagnostics.get(filePath) ?? [])])
|
||||
const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => {
|
||||
pushDiagnostics.set(filePath, next)
|
||||
void busRuntime.runPromise((svc) =>
|
||||
svc
|
||||
.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })
|
||||
.pipe(Effect.provideService(InstanceRef, instance)),
|
||||
)
|
||||
Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })
|
||||
}
|
||||
const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => {
|
||||
pullDiagnostics.set(filePath, next)
|
||||
@@ -525,14 +510,10 @@ export async function create(input: {
|
||||
}
|
||||
|
||||
timeoutTimer = setTimeout(() => finish(false), request.timeout)
|
||||
unsub = busRuntime.runSync((svc) =>
|
||||
svc
|
||||
.subscribeCallback(Event.Diagnostics, (event) => {
|
||||
if (event.properties.path !== request.path || event.properties.serverID !== input.serverID) return
|
||||
schedule()
|
||||
})
|
||||
.pipe(Effect.provideService(InstanceRef, instance)),
|
||||
)
|
||||
unsub = Bus.subscribe(Event.Diagnostics, (event) => {
|
||||
if (event.properties.path !== request.path || event.properties.serverID !== input.serverID) return
|
||||
schedule()
|
||||
})
|
||||
schedule()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -237,7 +237,6 @@ export const layer = Layer.effect(
|
||||
server: handle,
|
||||
root,
|
||||
directory: ctx.directory,
|
||||
instance: ctx,
|
||||
}).catch(async (err) => {
|
||||
s.broken.add(key)
|
||||
await Process.stop(handle.process)
|
||||
|
||||
@@ -6,7 +6,8 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||
import { text } from "node:stream/consumers"
|
||||
import fs from "fs/promises"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import type { InstanceContext } from "../project/instance-context"
|
||||
import type { InstanceContext } from "../project/instance"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Archive } from "@/util/archive"
|
||||
import { Process } from "@/util/process"
|
||||
import { which } from "../util/which"
|
||||
@@ -125,11 +126,11 @@ export const Vue: Info = {
|
||||
id: "vue",
|
||||
extensions: [".vue"],
|
||||
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
|
||||
async spawn(root, _ctx, flags) {
|
||||
async spawn(root) {
|
||||
let binary = which("vue-language-server")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
if (flags.disableLspDownload) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
const resolved = await Npm.which("@vue/language-server")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
@@ -154,13 +155,13 @@ export const ESLint: Info = {
|
||||
id: "eslint",
|
||||
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"],
|
||||
async spawn(root, ctx, flags) {
|
||||
async spawn(root, ctx) {
|
||||
const eslint = Module.resolve("eslint", ctx.directory)
|
||||
if (!eslint) return
|
||||
log.info("spawning eslint server")
|
||||
const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js")
|
||||
if (!(await Filesystem.exists(serverPath))) {
|
||||
if (flags.disableLspDownload) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
log.info("downloading and building VS Code ESLint server")
|
||||
const response = await fetch("https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip")
|
||||
if (!response.ok) return
|
||||
@@ -350,11 +351,11 @@ export const Gopls: Info = {
|
||||
return NearestRoot(["go.mod", "go.sum"])(file, ctx)
|
||||
},
|
||||
extensions: [".go"],
|
||||
async spawn(root, _ctx, flags) {
|
||||
async spawn(root) {
|
||||
let bin = which("gopls")
|
||||
if (!bin) {
|
||||
if (!which("go")) return
|
||||
if (flags.disableLspDownload) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
|
||||
log.info("installing gopls")
|
||||
const proc = Process.spawn(["go", "install", "golang.org/x/tools/gopls@latest"], {
|
||||
@@ -385,7 +386,7 @@ export const Rubocop: Info = {
|
||||
id: "ruby-lsp",
|
||||
root: NearestRoot(["Gemfile"]),
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async spawn(root, _ctx, flags) {
|
||||
async spawn(root) {
|
||||
let bin = which("rubocop")
|
||||
if (!bin) {
|
||||
const ruby = which("ruby")
|
||||
@@ -394,7 +395,7 @@ export const Rubocop: Info = {
|
||||
log.info("Ruby not found, please install Ruby first")
|
||||
return
|
||||
}
|
||||
if (flags.disableLspDownload) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
log.info("installing rubocop")
|
||||
const proc = Process.spawn(["gem", "install", "rubocop", "--bindir", Global.Path.bin], {
|
||||
stdout: "pipe",
|
||||
@@ -485,11 +486,11 @@ export const Pyright: Info = {
|
||||
id: "pyright",
|
||||
extensions: [".py", ".pyi"],
|
||||
root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]),
|
||||
async spawn(root, _ctx, flags) {
|
||||
async spawn(root) {
|
||||
let binary = which("pyright-langserver")
|
||||
const args = []
|
||||
if (!binary) {
|
||||
if (flags.disableLspDownload) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
const resolved = await Npm.which("pyright", "pyright-langserver")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
@@ -529,7 +530,7 @@ export const ElixirLS: Info = {
|
||||
id: "elixir-ls",
|
||||
extensions: [".ex", ".exs"],
|
||||
root: NearestRoot(["mix.exs", "mix.lock"]),
|
||||
async spawn(root, _ctx, flags) {
|
||||
async spawn(root) {
|
||||
let binary = which("elixir-ls")
|
||||
if (!binary) {
|
||||
const elixirLsPath = path.join(Global.Path.bin, "elixir-ls")
|
||||
@@ -547,7 +548,7 @@ export const ElixirLS: Info = {
|
||||
return
|
||||
}
|
||||
|
||||
if (flags.disableLspDownload) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
log.info("downloading elixir-ls from GitHub releases")
|
||||
|
||||
const response = await fetch("https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip")
|
||||
@@ -592,7 +593,7 @@ export const Zls: Info = {
|
||||
id: "zls",
|
||||
extensions: [".zig", ".zon"],
|
||||
root: NearestRoot(["build.zig"]),
|
||||
async spawn(root, _ctx, flags) {
|
||||
async spawn(root) {
|
||||
let bin = which("zls")
|
||||
|
||||
if (!bin) {
|
||||
@@ -602,7 +603,7 @@ export const Zls: Info = {
|
||||
return
|
||||
}
|
||||
|
||||
if (flags.disableLspDownload) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
log.info("downloading zls from GitHub releases")
|
||||
|
||||
const releaseResponse = await fetch("https://api.github.com/repos/zigtools/zls/releases/latest")
|
||||
@@ -704,8 +705,8 @@ export const CSharp: Info = {
|
||||
id: "csharp",
|
||||
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
|
||||
extensions: [".cs", ".csx"],
|
||||
async spawn(root, _ctx, flags) {
|
||||
const bin = await getRoslynLanguageServer(flags.disableLspDownload)
|
||||
async spawn(root) {
|
||||
const bin = await getRoslynLanguageServer()
|
||||
if (!bin) return
|
||||
|
||||
return {
|
||||
@@ -720,8 +721,8 @@ export const Razor: Info = {
|
||||
id: "razor",
|
||||
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
|
||||
extensions: [".razor", ".cshtml"],
|
||||
async spawn(root, _ctx, flags) {
|
||||
const bin = await getRoslynLanguageServer(flags.disableLspDownload)
|
||||
async spawn(root) {
|
||||
const bin = await getRoslynLanguageServer()
|
||||
if (!bin) return
|
||||
|
||||
const razor = await findVscodeRazorExtension()
|
||||
@@ -752,26 +753,26 @@ export const Razor: Info = {
|
||||
|
||||
let roslynLanguageServerInstall: Promise<string | undefined> | undefined
|
||||
|
||||
async function getRoslynLanguageServer(disableLspDownload: boolean) {
|
||||
async function getRoslynLanguageServer() {
|
||||
const existing = which("roslyn-language-server")
|
||||
if (existing) return existing
|
||||
|
||||
const global = await roslynLanguageServerGlobalPath()
|
||||
if (global) return global
|
||||
|
||||
roslynLanguageServerInstall ||= installRoslynLanguageServer(disableLspDownload).finally(() => {
|
||||
roslynLanguageServerInstall ||= installRoslynLanguageServer().finally(() => {
|
||||
roslynLanguageServerInstall = undefined
|
||||
})
|
||||
return roslynLanguageServerInstall
|
||||
}
|
||||
|
||||
async function installRoslynLanguageServer(disableLspDownload: boolean) {
|
||||
async function installRoslynLanguageServer() {
|
||||
if (!which("dotnet")) {
|
||||
log.error(".NET SDK is required to install roslyn-language-server")
|
||||
return
|
||||
}
|
||||
|
||||
if (disableLspDownload) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
log.info("installing roslyn-language-server via dotnet tool")
|
||||
const proc = Process.spawn(["dotnet", "tool", "install", "--global", "roslyn-language-server", "--prerelease"], {
|
||||
stdout: "pipe",
|
||||
@@ -849,7 +850,7 @@ export const FSharp: Info = {
|
||||
id: "fsharp",
|
||||
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
|
||||
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
|
||||
async spawn(root, _ctx, flags) {
|
||||
async spawn(root) {
|
||||
let bin = which("fsautocomplete")
|
||||
if (!bin) {
|
||||
if (!which("dotnet")) {
|
||||
@@ -857,7 +858,7 @@ export const FSharp: Info = {
|
||||
return
|
||||
}
|
||||
|
||||
if (flags.disableLspDownload) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
log.info("installing fsautocomplete via dotnet tool")
|
||||
const proc = Process.spawn(["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin], {
|
||||
stdout: "pipe",
|
||||
@@ -966,7 +967,7 @@ export const Clangd: Info = {
|
||||
id: "clangd",
|
||||
root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd"]),
|
||||
extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"],
|
||||
async spawn(root, _ctx, flags) {
|
||||
async spawn(root) {
|
||||
const args = ["--background-index", "--clang-tidy"]
|
||||
const fromPath = which("clangd")
|
||||
if (fromPath) {
|
||||
@@ -1001,7 +1002,7 @@ export const Clangd: Info = {
|
||||
}
|
||||
}
|
||||
|
||||
if (flags.disableLspDownload) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
log.info("downloading clangd from GitHub releases")
|
||||
|
||||
const releaseResponse = await fetch("https://api.github.com/repos/clangd/clangd/releases/latest")
|
||||
@@ -1112,11 +1113,11 @@ export const Svelte: Info = {
|
||||
id: "svelte",
|
||||
extensions: [".svelte"],
|
||||
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
|
||||
async spawn(root, _ctx, flags) {
|
||||
async spawn(root) {
|
||||
let binary = which("svelteserver")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
if (flags.disableLspDownload) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
const resolved = await Npm.which("svelte-language-server")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
@@ -1139,7 +1140,7 @@ export const Astro: Info = {
|
||||
id: "astro",
|
||||
extensions: [".astro"],
|
||||
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
|
||||
async spawn(root, ctx, flags) {
|
||||
async spawn(root, ctx) {
|
||||
const tsserver = Module.resolve("typescript/lib/tsserver.js", ctx.directory)
|
||||
if (!tsserver) {
|
||||
log.info("typescript not found, required for Astro language server")
|
||||
@@ -1150,7 +1151,7 @@ export const Astro: Info = {
|
||||
let binary = which("astro-ls")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
if (flags.disableLspDownload) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
const resolved = await Npm.which("@astrojs/language-server")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
@@ -1200,7 +1201,7 @@ export const JDTLS: Info = {
|
||||
if (settingsRoot) return settingsRoot
|
||||
},
|
||||
extensions: [".java"],
|
||||
async spawn(root, _ctx, flags) {
|
||||
async spawn(root) {
|
||||
const java = which("java")
|
||||
if (!java) {
|
||||
log.error("Java 21 or newer is required to run the JDTLS. Please install it first.")
|
||||
@@ -1218,7 +1219,7 @@ export const JDTLS: Info = {
|
||||
const launcherDir = path.join(distPath, "plugins")
|
||||
const installed = await pathExists(launcherDir)
|
||||
if (!installed) {
|
||||
if (flags.disableLspDownload) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
log.info("Downloading JDTLS LSP server.")
|
||||
await fs.mkdir(distPath, { recursive: true })
|
||||
const releaseURL =
|
||||
@@ -1310,13 +1311,13 @@ export const KotlinLS: Info = {
|
||||
// 4) Maven fallback
|
||||
return NearestRoot(["pom.xml"])(file, ctx)
|
||||
},
|
||||
async spawn(root, _ctx, flags) {
|
||||
async spawn(root) {
|
||||
const distPath = path.join(Global.Path.bin, "kotlin-ls")
|
||||
const launcherScript =
|
||||
process.platform === "win32" ? path.join(distPath, "kotlin-lsp.cmd") : path.join(distPath, "kotlin-lsp.sh")
|
||||
const installed = await Filesystem.exists(launcherScript)
|
||||
if (!installed) {
|
||||
if (flags.disableLspDownload) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
log.info("Downloading Kotlin Language Server from GitHub.")
|
||||
|
||||
const releaseResponse = await fetch("https://api.github.com/repos/Kotlin/kotlin-lsp/releases/latest")
|
||||
@@ -1397,11 +1398,11 @@ export const YamlLS: Info = {
|
||||
id: "yaml-ls",
|
||||
extensions: [".yaml", ".yml"],
|
||||
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
|
||||
async spawn(root, _ctx, flags) {
|
||||
async spawn(root) {
|
||||
let binary = which("yaml-language-server")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
if (flags.disableLspDownload) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
const resolved = await Npm.which("yaml-language-server")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
@@ -1431,11 +1432,11 @@ export const LuaLS: Info = {
|
||||
"selene.yml",
|
||||
]),
|
||||
extensions: [".lua"],
|
||||
async spawn(root, _ctx, flags) {
|
||||
async spawn(root) {
|
||||
let bin = which("lua-language-server")
|
||||
|
||||
if (!bin) {
|
||||
if (flags.disableLspDownload) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
log.info("downloading lua-language-server from GitHub releases")
|
||||
|
||||
const releaseResponse = await fetch("https://api.github.com/repos/LuaLS/lua-language-server/releases/latest")
|
||||
@@ -1564,11 +1565,11 @@ export const PHPIntelephense: Info = {
|
||||
id: "php intelephense",
|
||||
extensions: [".php"],
|
||||
root: NearestRoot(["composer.json", "composer.lock", ".php-version"]),
|
||||
async spawn(root, _ctx, flags) {
|
||||
async spawn(root) {
|
||||
let binary = which("intelephense")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
if (flags.disableLspDownload) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
const resolved = await Npm.which("intelephense")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
@@ -1648,11 +1649,11 @@ export const BashLS: Info = {
|
||||
id: "bash",
|
||||
extensions: [".sh", ".bash", ".zsh", ".ksh"],
|
||||
root: async (_file, ctx) => ctx.directory,
|
||||
async spawn(root, _ctx, flags) {
|
||||
async spawn(root) {
|
||||
let binary = which("bash-language-server")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
if (flags.disableLspDownload) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
const resolved = await Npm.which("bash-language-server")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
@@ -1674,11 +1675,11 @@ export const TerraformLS: Info = {
|
||||
id: "terraform",
|
||||
extensions: [".tf", ".tfvars"],
|
||||
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
|
||||
async spawn(root, _ctx, flags) {
|
||||
async spawn(root) {
|
||||
let bin = which("terraform-ls")
|
||||
|
||||
if (!bin) {
|
||||
if (flags.disableLspDownload) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
log.info("downloading terraform-ls from HashiCorp releases")
|
||||
|
||||
const releaseResponse = await fetch("https://api.releases.hashicorp.com/v1/releases/terraform-ls/latest")
|
||||
@@ -1755,11 +1756,11 @@ export const TexLab: Info = {
|
||||
id: "texlab",
|
||||
extensions: [".tex", ".bib"],
|
||||
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
|
||||
async spawn(root, _ctx, flags) {
|
||||
async spawn(root) {
|
||||
let bin = which("texlab")
|
||||
|
||||
if (!bin) {
|
||||
if (flags.disableLspDownload) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
log.info("downloading texlab from GitHub releases")
|
||||
|
||||
const response = await fetch("https://api.github.com/repos/latex-lsp/texlab/releases/latest")
|
||||
@@ -1843,11 +1844,11 @@ export const DockerfileLS: Info = {
|
||||
id: "dockerfile",
|
||||
extensions: [".dockerfile", "Dockerfile"],
|
||||
root: async (_file, ctx) => ctx.directory,
|
||||
async spawn(root, _ctx, flags) {
|
||||
async spawn(root) {
|
||||
let binary = which("docker-langserver")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
if (flags.disableLspDownload) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
const resolved = await Npm.which("dockerfile-language-server-nodejs")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
@@ -1939,11 +1940,11 @@ export const Tinymist: Info = {
|
||||
id: "tinymist",
|
||||
extensions: [".typ", ".typc"],
|
||||
root: NearestRoot(["typst.toml"]),
|
||||
async spawn(root, _ctx, flags) {
|
||||
async spawn(root) {
|
||||
let bin = which("tinymist")
|
||||
|
||||
if (!bin) {
|
||||
if (flags.disableLspDownload) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
log.info("downloading tinymist from GitHub releases")
|
||||
|
||||
const response = await fetch("https://api.github.com/repos/Myriad-Dreamin/tinymist/releases/latest")
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { context, type InstanceContext } from "./instance-context"
|
||||
|
||||
export type { InstanceContext } from "./instance-context"
|
||||
|
||||
export const Instance = {
|
||||
get current() {
|
||||
return context.use()
|
||||
},
|
||||
get directory() {
|
||||
return context.use().directory
|
||||
},
|
||||
get worktree() {
|
||||
return context.use().worktree
|
||||
},
|
||||
get project() {
|
||||
return context.use().project
|
||||
},
|
||||
|
||||
/**
|
||||
* Captures the current instance ALS context and returns a wrapper that
|
||||
* restores it when called. Use this for callbacks that fire outside the
|
||||
* instance async context (native addons, event emitters, timers, etc.).
|
||||
*/
|
||||
bind<F extends (...args: any[]) => any>(fn: F): F {
|
||||
const ctx = context.use()
|
||||
return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F
|
||||
},
|
||||
/**
|
||||
* Run a synchronous function within the given instance context ALS.
|
||||
* Use this to bridge from Effect (where InstanceRef carries context)
|
||||
* back to sync code that reads Instance.directory from ALS.
|
||||
*/
|
||||
restore<R>(ctx: InstanceContext, fn: () => R): R {
|
||||
return context.provide(ctx, fn)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { context } from "./instance-context"
|
||||
import { InstanceStore } from "./instance-store"
|
||||
|
||||
export async function provide<R>(input: { directory: string; fn: () => R }): Promise<R> {
|
||||
const ctx = await AppRuntime.runPromise(
|
||||
InstanceStore.Service.use((store) => store.load({ directory: input.directory })),
|
||||
)
|
||||
return context.provide(ctx, () => input.fn())
|
||||
}
|
||||
|
||||
export * as WithInstance from "./with-instance"
|
||||
@@ -4,6 +4,7 @@ import type { JSONSchema7 } from "@ai-sdk/provider"
|
||||
import type * as Provider from "./provider"
|
||||
import type * as ModelsDev from "@opencode-ai/core/models"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
|
||||
type Modality = NonNullable<ModelsDev.Model["modalities"]>["input"][number]
|
||||
|
||||
@@ -15,7 +16,7 @@ function mimeToModality(mime: string): Modality | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const OUTPUT_TOKEN_MAX = 32_000
|
||||
export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000
|
||||
|
||||
export function sanitizeSurrogates(content: string) {
|
||||
return content.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD")
|
||||
@@ -1250,8 +1251,8 @@ export function providerOptions(model: Provider.Model, options: { [x: string]: a
|
||||
return { [key]: options }
|
||||
}
|
||||
|
||||
export function maxOutputTokens(model: Provider.Model, outputTokenMax = OUTPUT_TOKEN_MAX): number {
|
||||
return Math.min(model.limit.output, outputTokenMax) || outputTokenMax
|
||||
export function maxOutputTokens(model: Provider.Model): number {
|
||||
return Math.min(model.limit.output, OUTPUT_TOKEN_MAX) || OUTPUT_TOKEN_MAX
|
||||
}
|
||||
|
||||
export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 {
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { initProjectors } from "./projectors"
|
||||
|
||||
initProjectors()
|
||||
+18
-18
@@ -1,28 +1,28 @@
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { Instance } from "@opencode-ai/core/instance"
|
||||
import { InstanceServiceMap } from "@opencode-ai/core/instance-layer"
|
||||
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { HttpServerRequest } from "effect/unstable/http"
|
||||
import { HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi"
|
||||
|
||||
export const LocationQuery = Schema.Struct({
|
||||
location: Schema.optional(
|
||||
export const InstanceQuery = Schema.Struct({
|
||||
instance: Schema.optional(
|
||||
Schema.Struct({
|
||||
directory: Schema.optional(Schema.String),
|
||||
workspace: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
}).annotate({ identifier: "V2LocationQuery" })
|
||||
}).annotate({ identifier: "V2InstanceQuery" })
|
||||
|
||||
export const locationQueryOpenApi = OpenApi.annotations({
|
||||
export const instanceQueryOpenApi = OpenApi.annotations({
|
||||
transform: (operation) => {
|
||||
const parameters = operation.parameters
|
||||
if (!Array.isArray(parameters)) return operation
|
||||
return {
|
||||
...operation,
|
||||
parameters: parameters.map((parameter) =>
|
||||
parameter?.name === "location" && parameter?.in === "query"
|
||||
parameter?.name === "instance" && parameter?.in === "query"
|
||||
? { ...parameter, style: "deepObject", explode: true }
|
||||
: parameter,
|
||||
),
|
||||
@@ -30,30 +30,30 @@ export const locationQueryOpenApi = OpenApi.annotations({
|
||||
},
|
||||
})
|
||||
|
||||
export class V2LocationMiddleware extends HttpApiMiddleware.Service<
|
||||
V2LocationMiddleware,
|
||||
export class V2InstanceMiddleware extends HttpApiMiddleware.Service<
|
||||
V2InstanceMiddleware,
|
||||
{
|
||||
provides: Catalog.Service | PluginBoot.Service
|
||||
}
|
||||
>()("@opencode/ExperimentalHttpApiV2Location") {}
|
||||
>()("@opencode/ExperimentalHttpApiV2Instance") {}
|
||||
|
||||
function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref {
|
||||
function ref(request: HttpServerRequest.HttpServerRequest): Instance.Ref {
|
||||
const query = new URL(request.url, "http://localhost").searchParams
|
||||
return {
|
||||
directory: query.get("location[directory]") || request.headers["x-opencode-directory"] || process.cwd(),
|
||||
workspaceID: query.get("location[workspace]") || request.headers["x-opencode-workspace"],
|
||||
directory: query.get("instance[directory]") || request.headers["x-opencode-directory"] || process.cwd(),
|
||||
workspaceID: query.get("instance[workspace]") || request.headers["x-opencode-workspace"],
|
||||
}
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
V2LocationMiddleware,
|
||||
V2InstanceMiddleware,
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap
|
||||
return V2LocationMiddleware.of((effect) =>
|
||||
const instances = yield* InstanceServiceMap
|
||||
return V2InstanceMiddleware.of((effect) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
return yield* effect.pipe(Effect.provide(locations.get(ref(request))))
|
||||
return yield* effect.pipe(Effect.provide(instances.get(ref(request))))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
).pipe(Layer.provide(LocationServiceMap.layer))
|
||||
).pipe(Layer.provide(InstanceServiceMap.layer))
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { SessionMessage } from "@opencode-ai/core/session-message"
|
||||
import { SessionMessage } from "@/v2/session-message"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../../middleware/authorization"
|
||||
|
||||
@@ -2,15 +2,15 @@ import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../../middleware/authorization"
|
||||
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
|
||||
import { InstanceQuery, instanceQueryOpenApi, V2InstanceMiddleware } from "./instance"
|
||||
|
||||
export const ModelGroup = HttpApiGroup.make("v2.model")
|
||||
.add(
|
||||
HttpApiEndpoint.get("models", "/api/model", {
|
||||
query: LocationQuery,
|
||||
query: InstanceQuery,
|
||||
success: Schema.Array(ModelV2.Info),
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(instanceQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.model.list",
|
||||
@@ -25,5 +25,5 @@ export const ModelGroup = HttpApiGroup.make("v2.model")
|
||||
description: "Experimental v2 model routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(V2LocationMiddleware)
|
||||
.middleware(V2InstanceMiddleware)
|
||||
.middleware(Authorization)
|
||||
|
||||
@@ -3,15 +3,15 @@ import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { ApiNotFoundError } from "../../errors"
|
||||
import { Authorization } from "../../middleware/authorization"
|
||||
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
|
||||
import { InstanceQuery, instanceQueryOpenApi, V2InstanceMiddleware } from "./instance"
|
||||
|
||||
export const ProviderGroup = HttpApiGroup.make("v2.provider")
|
||||
.add(
|
||||
HttpApiEndpoint.get("providers", "/api/provider", {
|
||||
query: LocationQuery,
|
||||
query: InstanceQuery,
|
||||
success: Schema.Array(ProviderV2.Info),
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(instanceQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.provider.list",
|
||||
@@ -23,11 +23,11 @@ export const ProviderGroup = HttpApiGroup.make("v2.provider")
|
||||
.add(
|
||||
HttpApiEndpoint.get("provider", "/api/provider/:providerID", {
|
||||
params: { providerID: ProviderV2.ID },
|
||||
query: LocationQuery,
|
||||
query: InstanceQuery,
|
||||
success: ProviderV2.Info,
|
||||
error: ApiNotFoundError,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(instanceQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.provider.get",
|
||||
@@ -43,5 +43,5 @@ export const ProviderGroup = HttpApiGroup.make("v2.provider")
|
||||
description: "Experimental v2 provider routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(V2LocationMiddleware)
|
||||
.middleware(V2InstanceMiddleware)
|
||||
.middleware(Authorization)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { SessionMessage } from "@opencode-ai/core/session-message"
|
||||
import { SessionMessage } from "@/v2/session-message"
|
||||
import { Prompt } from "@opencode-ai/core/session-prompt"
|
||||
import { SessionV2 } from "@/v2/session"
|
||||
import { Schema } from "effect"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { SessionV2 } from "@/v2/session"
|
||||
import { Layer } from "effect"
|
||||
import { layer as v2LocationLayer } from "../groups/v2/location"
|
||||
import { layer as v2InstanceLayer } from "../groups/v2/instance"
|
||||
import { messageHandlers } from "./v2/message"
|
||||
import { modelHandlers } from "./v2/model"
|
||||
import { providerHandlers } from "./v2/provider"
|
||||
import { sessionHandlers } from "./v2/session"
|
||||
|
||||
export const v2Handlers = Layer.mergeAll(sessionHandlers, messageHandlers, modelHandlers, providerHandlers).pipe(
|
||||
Layer.provide(v2LocationLayer),
|
||||
Layer.provide(v2InstanceLayer),
|
||||
Layer.provide(SessionV2.defaultLayer),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SessionMessage } from "@opencode-ai/core/session-message"
|
||||
import { SessionMessage } from "@/v2/session-message"
|
||||
import { SessionV2 } from "@/v2/session"
|
||||
import { Effect, Schema } from "effect"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
import type { InstanceContext } from "@/project/instance"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Effect } from "effect"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
|
||||
@@ -26,10 +26,9 @@ function provideInstanceContext<E>(
|
||||
): Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext> {
|
||||
return Effect.gen(function* () {
|
||||
const route = yield* WorkspaceRouteContext
|
||||
const ctx = yield* store.load({ directory: decode(route.directory) })
|
||||
return yield* effect.pipe(
|
||||
Effect.provideService(InstanceRef, ctx),
|
||||
Effect.provideService(WorkspaceRef, route.workspaceID),
|
||||
return yield* store.provide(
|
||||
{ directory: decode(route.directory) },
|
||||
effect.pipe(Effect.provideService(WorkspaceRef, route.workspaceID)),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
+4
-2
@@ -1,7 +1,8 @@
|
||||
import { getAdapter } from "@/control-plane/adapters"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import type { Target } from "@/control-plane/types"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { WorkspaceAdapterRuntime } from "@/control-plane/workspace-adapter-runtime"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { Session } from "@/session/session"
|
||||
import { HttpApiProxy } from "./proxy"
|
||||
import * as Fence from "@/server/shared/fence"
|
||||
@@ -92,7 +93,8 @@ function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServe
|
||||
}
|
||||
|
||||
function resolveTarget(workspace: Workspace.Info): Effect.Effect<Target> {
|
||||
return WorkspaceAdapterRuntime.target(workspace)
|
||||
const adapter = getAdapter(workspace.projectID, workspace.type)
|
||||
return EffectBridge.fromPromise(() => adapter.target(workspace))
|
||||
}
|
||||
|
||||
function proxyRemote(
|
||||
|
||||
@@ -45,7 +45,6 @@ import { SessionSummary } from "@/session/summary"
|
||||
import { Todo } from "@/session/todo"
|
||||
import { SessionShare } from "@/share/session"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { Skill } from "@/skill"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { SyncEvent } from "@/sync"
|
||||
@@ -222,7 +221,6 @@ export function createRoutes(
|
||||
ShareNext.defaultLayer,
|
||||
Snapshot.defaultLayer,
|
||||
SyncEvent.defaultLayer,
|
||||
EventV2Bridge.defaultLayer,
|
||||
Skill.defaultLayer,
|
||||
Todo.defaultLayer,
|
||||
ToolRegistry.defaultLayer,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import "./init-projectors"
|
||||
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { ConfigProvider, Context, Effect, Exit, Layer, Scope } from "effect"
|
||||
@@ -7,6 +5,7 @@ import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import { createServer } from "node:http"
|
||||
import { MDNS } from "./mdns"
|
||||
import { initProjectors } from "./projectors"
|
||||
import { HttpApiApp } from "./routes/instance/httpapi/server"
|
||||
import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle"
|
||||
import { WebSocketTracker } from "./routes/instance/httpapi/websocket-tracker"
|
||||
@@ -17,6 +16,8 @@ import { lazy } from "@/util/lazy"
|
||||
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
|
||||
initProjectors()
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
export type Listener = {
|
||||
|
||||
@@ -19,9 +19,8 @@ import { isOverflow as overflow, usable } from "./overflow"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { SessionEvent } from "@opencode-ai/core/session-event"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { SessionEvent } from "@/v2/session-event"
|
||||
|
||||
const log = Log.create({ service: "session.compaction" })
|
||||
|
||||
@@ -211,7 +210,19 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
|
||||
|
||||
export const use = serviceUse(Service)
|
||||
|
||||
export const layer = Layer.effect(
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
| Bus.Service
|
||||
| Config.Service
|
||||
| Session.Service
|
||||
| Agent.Service
|
||||
| Plugin.Service
|
||||
| SessionProcessor.Service
|
||||
| Provider.Service
|
||||
| SyncEvent.Service
|
||||
| RuntimeFlags.Service
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
@@ -221,19 +232,14 @@ export const layer = Layer.effect(
|
||||
const plugin = yield* Plugin.Service
|
||||
const processors = yield* SessionProcessor.Service
|
||||
const provider = yield* Provider.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const sync = yield* SyncEvent.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
|
||||
const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: {
|
||||
tokens: MessageV2.Assistant["tokens"]
|
||||
model: Provider.Model
|
||||
}) {
|
||||
return overflow({
|
||||
cfg: yield* config.get(),
|
||||
tokens: input.tokens,
|
||||
model: input.model,
|
||||
outputTokenMax: flags.outputTokenMax,
|
||||
})
|
||||
return overflow({ cfg: yield* config.get(), tokens: input.tokens, model: input.model })
|
||||
})
|
||||
|
||||
const estimate = Effect.fn("SessionCompaction.estimate")(function* (input: {
|
||||
@@ -571,7 +577,7 @@ export const layer = Layer.effect(
|
||||
},
|
||||
)
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Compaction.Ended, {
|
||||
yield* sync.run(SessionEvent.Compaction.Ended.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
text: summary ?? "",
|
||||
@@ -607,7 +613,7 @@ export const layer = Layer.effect(
|
||||
overflow: input.overflow,
|
||||
})
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Compaction.Started, {
|
||||
yield* sync.run(SessionEvent.Compaction.Started.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
reason: input.auto ? "auto" : "manual",
|
||||
@@ -633,8 +639,8 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(SyncEvent.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Effect, Layer, Context } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { Config } from "@/config/config"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
@@ -11,9 +10,9 @@ import { Global } from "@opencode-ai/core/global"
|
||||
import type { MessageV2 } from "./message-v2"
|
||||
import type { MessageID } from "./schema"
|
||||
|
||||
const files = (disableClaudeCodePrompt: boolean) => [
|
||||
const FILES = [
|
||||
"AGENTS.md",
|
||||
...(disableClaudeCodePrompt ? [] : ["CLAUDE.md"]),
|
||||
...(Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT ? [] : ["CLAUDE.md"]),
|
||||
"CONTEXT.md", // deprecated
|
||||
]
|
||||
|
||||
@@ -51,20 +50,18 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/In
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
AppFileSystem.Service | Config.Service | Global.Service | HttpClient.HttpClient | RuntimeFlags.Service
|
||||
AppFileSystem.Service | Config.Service | Global.Service | HttpClient.HttpClient
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const cfg = yield* Config.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const global = yield* Global.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
|
||||
const globalFiles = [
|
||||
path.join(global.config, "AGENTS.md"),
|
||||
...(!flags.disableClaudeCodePrompt ? [path.join(global.home, ".claude", "CLAUDE.md")] : []),
|
||||
...(!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT ? [path.join(global.home, ".claude", "CLAUDE.md")] : []),
|
||||
]
|
||||
const instructionFiles = files(flags.disableClaudeCodePrompt)
|
||||
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("Instruction.state")(() =>
|
||||
@@ -120,10 +117,8 @@ export const layer: Layer.Layer<
|
||||
|
||||
// The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor.
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
for (const file of instructionFiles) {
|
||||
const matches = yield* fs
|
||||
.findUp(file, ctx.directory, ctx.worktree)
|
||||
.pipe(Effect.catch(() => Effect.succeed([])))
|
||||
for (const file of FILES) {
|
||||
const matches = yield* fs.findUp(file, ctx.directory, ctx.worktree)
|
||||
if (matches.length > 0) {
|
||||
matches.forEach((item) => paths.add(path.resolve(item)))
|
||||
break
|
||||
@@ -168,7 +163,7 @@ export const layer: Layer.Layer<
|
||||
})
|
||||
|
||||
const find = Effect.fn("Instruction.find")(function* (dir: string) {
|
||||
for (const file of instructionFiles) {
|
||||
for (const file of FILES) {
|
||||
const filepath = path.resolve(path.join(dir, file))
|
||||
if (yield* fs.existsSafe(filepath)) return filepath
|
||||
}
|
||||
@@ -228,7 +223,6 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Global.layer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
)
|
||||
|
||||
export function loaded(messages: MessageV2.WithParts[]) {
|
||||
|
||||
@@ -173,7 +173,7 @@ const live: Layer.Layer<
|
||||
: undefined,
|
||||
topP: input.agent.topP ?? ProviderTransform.topP(input.model),
|
||||
topK: ProviderTransform.topK(input.model),
|
||||
maxOutputTokens: ProviderTransform.maxOutputTokens(input.model, flags.outputTokenMax),
|
||||
maxOutputTokens: ProviderTransform.maxOutputTokens(input.model),
|
||||
options,
|
||||
},
|
||||
)
|
||||
@@ -256,7 +256,7 @@ const live: Layer.Layer<
|
||||
|
||||
const bridge = yield* EffectBridge.make()
|
||||
const approvedToolsForSession = new Set<string>()
|
||||
workflowModel.approvalHandler = bridge.bind(async (approvalTools) => {
|
||||
workflowModel.approvalHandler = InstanceState.bind(async (approvalTools) => {
|
||||
const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[]
|
||||
// Auto-approve tools that were already approved in this session
|
||||
// (prevents infinite approval loops for server-side MCP tools)
|
||||
|
||||
@@ -5,24 +5,18 @@ import type { MessageV2 } from "./message-v2"
|
||||
|
||||
const COMPACTION_BUFFER = 20_000
|
||||
|
||||
export function usable(input: { cfg: Config.Info; model: Provider.Model; outputTokenMax?: number }) {
|
||||
export function usable(input: { cfg: Config.Info; model: Provider.Model }) {
|
||||
const context = input.model.limit.context
|
||||
if (context === 0) return 0
|
||||
|
||||
const reserved =
|
||||
input.cfg.compaction?.reserved ??
|
||||
Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model, input.outputTokenMax))
|
||||
input.cfg.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model))
|
||||
return input.model.limit.input
|
||||
? Math.max(0, input.model.limit.input - reserved)
|
||||
: Math.max(0, context - ProviderTransform.maxOutputTokens(input.model, input.outputTokenMax))
|
||||
: Math.max(0, context - ProviderTransform.maxOutputTokens(input.model))
|
||||
}
|
||||
|
||||
export function isOverflow(input: {
|
||||
cfg: Config.Info
|
||||
tokens: MessageV2.Assistant["tokens"]
|
||||
model: Provider.Model
|
||||
outputTokenMax?: number
|
||||
}) {
|
||||
export function isOverflow(input: { cfg: Config.Info; tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
|
||||
if (input.cfg.compaction?.auto === false) return false
|
||||
if (input.model.limit.context === 0) return false
|
||||
|
||||
|
||||
@@ -21,9 +21,8 @@ import { Question } from "@/question"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { SessionEvent } from "@opencode-ai/core/session-event"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { SessionEvent } from "@/v2/session-event"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
@@ -85,7 +84,23 @@ type StreamEvent = Event
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionProcessor") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
| Session.Service
|
||||
| Config.Service
|
||||
| Bus.Service
|
||||
| Snapshot.Service
|
||||
| Agent.Service
|
||||
| LLM.Service
|
||||
| Permission.Service
|
||||
| Plugin.Service
|
||||
| Image.Service
|
||||
| SessionSummary.Service
|
||||
| SessionStatus.Service
|
||||
| SyncEvent.Service
|
||||
| RuntimeFlags.Service
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
@@ -100,7 +115,7 @@ export const layer = Layer.effect(
|
||||
const scope = yield* Scope.Scope
|
||||
const status = yield* SessionStatus.Service
|
||||
const image = yield* Image.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const sync = yield* SyncEvent.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
|
||||
const create = Effect.fn("SessionProcessor.create")(function* (input: Input) {
|
||||
@@ -221,7 +236,7 @@ export const layer = Layer.effect(
|
||||
if (value.id in ctx.reasoningMap) return
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Reasoning.Started, {
|
||||
yield* sync.run(SessionEvent.Reasoning.Started.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
reasoningID: value.id,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
@@ -256,7 +271,7 @@ export const layer = Layer.effect(
|
||||
if (!(value.id in ctx.reasoningMap)) return
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Reasoning.Ended, {
|
||||
yield* sync.run(SessionEvent.Reasoning.Ended.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
reasoningID: value.id,
|
||||
text: ctx.reasoningMap[value.id].text,
|
||||
@@ -277,7 +292,7 @@ export const layer = Layer.effect(
|
||||
}
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
||||
yield* sync.run(SessionEvent.Tool.Input.Started.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.id,
|
||||
name: value.toolName,
|
||||
@@ -308,7 +323,7 @@ export const layer = Layer.effect(
|
||||
case "tool-input-end": {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Tool.Input.Ended, {
|
||||
yield* sync.run(SessionEvent.Tool.Input.Ended.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.id,
|
||||
text: "",
|
||||
@@ -325,7 +340,7 @@ export const layer = Layer.effect(
|
||||
const toolCall = yield* readToolCall(value.toolCallId)
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Tool.Called, {
|
||||
yield* sync.run(SessionEvent.Tool.Called.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.toolCallId,
|
||||
tool: value.toolName,
|
||||
@@ -413,7 +428,7 @@ export const layer = Layer.effect(
|
||||
}
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Tool.Success, {
|
||||
yield* sync.run(SessionEvent.Tool.Success.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.toolCallId,
|
||||
structured: output.metadata,
|
||||
@@ -443,7 +458,7 @@ export const layer = Layer.effect(
|
||||
const toolCall = yield* readToolCall(value.toolCallId)
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
yield* sync.run(SessionEvent.Tool.Failed.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.toolCallId,
|
||||
error: {
|
||||
@@ -468,7 +483,7 @@ export const layer = Layer.effect(
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
yield* sync.run(SessionEvent.Step.Started.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
agent: input.assistantMessage.agent,
|
||||
model: {
|
||||
@@ -500,7 +515,7 @@ export const layer = Layer.effect(
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Step.Ended, {
|
||||
yield* sync.run(SessionEvent.Step.Ended.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
finish: value.finishReason,
|
||||
cost: usage.cost,
|
||||
@@ -557,7 +572,7 @@ export const layer = Layer.effect(
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Text.Started, {
|
||||
yield* sync.run(SessionEvent.Text.Started.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
@@ -604,7 +619,7 @@ export const layer = Layer.effect(
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Text.Ended, {
|
||||
yield* sync.run(SessionEvent.Text.Ended.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
text: ctx.currentText.text,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
@@ -700,7 +715,7 @@ export const layer = Layer.effect(
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Step.Failed, {
|
||||
yield* sync.run(SessionEvent.Step.Failed.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
error: {
|
||||
type: "unknown",
|
||||
@@ -754,7 +769,7 @@ export const layer = Layer.effect(
|
||||
set: (info) => {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
const event = flags.experimentalEventSystem
|
||||
? events.publish(SessionEvent.Retried, {
|
||||
? sync.run(SessionEvent.Retried.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
attempt: info.attempt,
|
||||
error: {
|
||||
@@ -815,8 +830,8 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
Layer.provide(Image.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(SyncEvent.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { and, desc, eq } from "@/storage/db"
|
||||
import type { Database } from "@/storage/db"
|
||||
import { SessionMessage } from "@opencode-ai/core/session-message"
|
||||
import { SessionMessageUpdater } from "@opencode-ai/core/session-message-updater"
|
||||
import { SessionEvent } from "@opencode-ai/core/session-event"
|
||||
import { SessionMessage } from "@/v2/session-message"
|
||||
import { SessionMessageUpdater } from "@/v2/session-message-updater"
|
||||
import { SessionEvent } from "@/v2/session-event"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { SessionMessageTable, SessionTable } from "./session.sql"
|
||||
import type { SessionID } from "./schema"
|
||||
import { Schema } from "effect"
|
||||
@@ -120,7 +119,7 @@ function update(db: Database.TxOrDb, event: SessionEvent.Event) {
|
||||
}
|
||||
|
||||
export default [
|
||||
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.AgentSwitched), (db, data, event) => {
|
||||
SyncEvent.project(SessionEvent.AgentSwitched.Sync, (db, data, event) => {
|
||||
db.update(SessionTable)
|
||||
.set({
|
||||
agent: data.agent,
|
||||
@@ -130,7 +129,7 @@ export default [
|
||||
.run()
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.agent.switched", data })
|
||||
}),
|
||||
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.ModelSwitched), (db, data, event) => {
|
||||
SyncEvent.project(SessionEvent.ModelSwitched.Sync, (db, data, event) => {
|
||||
db.update(SessionTable)
|
||||
.set({
|
||||
model: data.model,
|
||||
@@ -140,65 +139,65 @@ export default [
|
||||
.run()
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.model.switched", data })
|
||||
}),
|
||||
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Prompted), (db, data, event) => {
|
||||
SyncEvent.project(SessionEvent.Prompted.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.prompted", data })
|
||||
}),
|
||||
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Synthetic), (db, data, event) => {
|
||||
SyncEvent.project(SessionEvent.Synthetic.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.synthetic", data })
|
||||
}),
|
||||
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Shell.Started), (db, data, event) => {
|
||||
SyncEvent.project(SessionEvent.Shell.Started.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.shell.started", data })
|
||||
}),
|
||||
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Shell.Ended), (db, data, event) => {
|
||||
SyncEvent.project(SessionEvent.Shell.Ended.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.shell.ended", data })
|
||||
}),
|
||||
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Step.Started), (db, data, event) => {
|
||||
SyncEvent.project(SessionEvent.Step.Started.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.started", data })
|
||||
}),
|
||||
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Step.Ended), (db, data, event) => {
|
||||
SyncEvent.project(SessionEvent.Step.Ended.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.ended", data })
|
||||
}),
|
||||
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Step.Failed), (db, data, event) => {
|
||||
SyncEvent.project(SessionEvent.Step.Failed.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.failed", data })
|
||||
}),
|
||||
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Text.Started), (db, data, event) => {
|
||||
SyncEvent.project(SessionEvent.Text.Started.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.started", data })
|
||||
}),
|
||||
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Text.Delta), () => {}),
|
||||
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Text.Ended), (db, data, event) => {
|
||||
SyncEvent.project(SessionEvent.Text.Delta.Sync, () => {}),
|
||||
SyncEvent.project(SessionEvent.Text.Ended.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.ended", data })
|
||||
}),
|
||||
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Tool.Input.Started), (db, data, event) => {
|
||||
SyncEvent.project(SessionEvent.Tool.Input.Started.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.input.started", data })
|
||||
}),
|
||||
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Tool.Input.Delta), () => {}),
|
||||
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Tool.Input.Ended), (db, data, event) => {
|
||||
SyncEvent.project(SessionEvent.Tool.Input.Delta.Sync, () => {}),
|
||||
SyncEvent.project(SessionEvent.Tool.Input.Ended.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.input.ended", data })
|
||||
}),
|
||||
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Tool.Called), (db, data, event) => {
|
||||
SyncEvent.project(SessionEvent.Tool.Called.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.called", data })
|
||||
}),
|
||||
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Tool.Success), (db, data, event) => {
|
||||
SyncEvent.project(SessionEvent.Tool.Success.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.success", data })
|
||||
}),
|
||||
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Tool.Failed), (db, data, event) => {
|
||||
SyncEvent.project(SessionEvent.Tool.Failed.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.failed", data })
|
||||
}),
|
||||
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Reasoning.Started), (db, data, event) => {
|
||||
SyncEvent.project(SessionEvent.Reasoning.Started.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.started", data })
|
||||
}),
|
||||
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Reasoning.Delta), () => {}),
|
||||
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Reasoning.Ended), (db, data, event) => {
|
||||
SyncEvent.project(SessionEvent.Reasoning.Delta.Sync, () => {}),
|
||||
SyncEvent.project(SessionEvent.Reasoning.Ended.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.ended", data })
|
||||
}),
|
||||
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Retried), (db, data, event) => {
|
||||
SyncEvent.project(SessionEvent.Retried.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.retried", data })
|
||||
}),
|
||||
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Compaction.Started), (db, data, event) => {
|
||||
SyncEvent.project(SessionEvent.Compaction.Started.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.compaction.started", data })
|
||||
}),
|
||||
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Compaction.Delta), () => {}),
|
||||
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Compaction.Ended), (db, data, event) => {
|
||||
SyncEvent.project(SessionEvent.Compaction.Delta.Sync, () => {}),
|
||||
SyncEvent.project(SessionEvent.Compaction.Ended.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.compaction.ended", data })
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -52,9 +52,8 @@ import { TaskTool, type TaskPromptOps } from "@/tool/task"
|
||||
import { SessionRunState } from "./run-state"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { SessionEvent } from "@opencode-ai/core/session-event"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { SessionEvent } from "@/v2/session-event"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AgentAttachment, FileAttachment, ReferenceAttachment, Source } from "@opencode-ai/core/session-prompt"
|
||||
@@ -205,7 +204,7 @@ export const layer = Layer.effect(
|
||||
const sys = yield* SystemPrompt.Service
|
||||
const llm = yield* LLM.Service
|
||||
const references = yield* Reference.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const sync = yield* SyncEvent.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const runner = Effect.fn("SessionPrompt.runner")(function* () {
|
||||
return yield* EffectBridge.make()
|
||||
@@ -945,6 +944,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
providerID: model.providerID,
|
||||
}
|
||||
yield* sessions.updateMessage(msg)
|
||||
const callID = ulid()
|
||||
const started = Date.now()
|
||||
const part: MessageV2.ToolPart = {
|
||||
type: "tool",
|
||||
@@ -961,10 +961,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}
|
||||
yield* sessions.updatePart(part)
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Shell.Started, {
|
||||
yield* sync.run(SessionEvent.Shell.Started.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(started),
|
||||
callID: part.callID,
|
||||
callID,
|
||||
command: input.command,
|
||||
})
|
||||
}
|
||||
@@ -984,7 +984,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}
|
||||
const completed = Date.now()
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Shell.Ended, {
|
||||
yield* sync.run(SessionEvent.Shell.Ended.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(completed),
|
||||
callID: part.callID,
|
||||
@@ -1134,7 +1134,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}
|
||||
|
||||
if (current?.agent !== info.agent) {
|
||||
yield* events.publish(SessionEvent.AgentSwitched, {
|
||||
yield* sync.run(SessionEvent.AgentSwitched.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
agent: info.agent,
|
||||
@@ -1145,7 +1145,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
current.model.id !== info.model.modelID ||
|
||||
(current.model.variant === "default" ? undefined : current.model.variant) !== info.model.variant
|
||||
) {
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
yield* sync.run(SessionEvent.ModelSwitched.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
model: {
|
||||
@@ -1586,7 +1586,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
)
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Prompted, {
|
||||
yield* sync.run(SessionEvent.Prompted.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
prompt: {
|
||||
@@ -1600,7 +1600,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
for (const text of nextPrompt.synthetic) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Synthetic, {
|
||||
yield* sync.run(SessionEvent.Synthetic.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
text,
|
||||
@@ -2038,13 +2038,13 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
Layer.provide(Image.defaultLayer),
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
EventV2Bridge.defaultLayer,
|
||||
Agent.defaultLayer,
|
||||
SystemPrompt.defaultLayer,
|
||||
LLM.defaultLayer,
|
||||
Reference.defaultLayer,
|
||||
Bus.layer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
SyncEvent.defaultLayer,
|
||||
RuntimeFlags.defaultLayer,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { Identifier } from "@/id/id"
|
||||
import { Session as CoreSession } from "@opencode-ai/core/session"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
export const SessionID = CoreSession.ID
|
||||
export const SessionID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
|
||||
Schema.brand("SessionID"),
|
||||
withStatics((s) => ({
|
||||
descending: (id?: string) => s.make(Identifier.descending("session", id)),
|
||||
})),
|
||||
)
|
||||
|
||||
export type SessionID = Schema.Schema.Type<typeof SessionID>
|
||||
|
||||
export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { sqliteTable, text, integer, index, primaryKey, real } from "drizzle-orm/sqlite-core"
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
import type { MessageV2 } from "./message-v2"
|
||||
import type { SessionMessage } from "@opencode-ai/core/session-message"
|
||||
import type { SessionMessage } from "../v2/session-message"
|
||||
import type { Snapshot } from "../snapshot"
|
||||
import type { Permission } from "../permission"
|
||||
import type { ProjectID } from "../project/schema"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user