Compare commits

..

1 Commits

Author SHA1 Message Date
James Long faa92ff5ea feat(sdk): import local credentials into embedded host 2026-08-11 14:24:44 +00:00
167 changed files with 1733 additions and 4950 deletions
+1 -56
View File
@@ -124,66 +124,12 @@ jobs:
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-preview-cli-unsigned
name: opencode-preview-cli
path: packages/cli/dist/cli-*
outputs:
version: ${{ needs.version.outputs.version }}
sign-cli-macos:
needs: build-cli
runs-on: macos-26
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2.0.0
with:
keychain: build
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: opencode-preview-cli-unsigned
path: packages/cli/dist
- name: Sign macOS CLI binaries
run: |
identity=$(security find-identity -v -p codesigning build.keychain | sed -n 's/.*"\(Developer ID Application:.*\)"/\1/p' | head -n 1)
if [ -z "$identity" ]; then
echo "Developer ID Application identity not found"
exit 1
fi
found=0
for file in packages/cli/dist/cli-darwin-*/bin/opencode2; do
if [ ! -f "$file" ]; then
continue
fi
found=1
codesign \
--force \
--timestamp \
--options runtime \
--entitlements packages/cli/script/entitlements.plist \
--sign "$identity" \
"$file"
codesign --verify --deep --strict --verbose=4 "$file"
codesign --display --requirements - "$file"
done
if [ "$found" -eq 0 ]; then
echo "No macOS CLI binaries found"
exit 1
fi
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-preview-cli
path: packages/cli/dist/cli-*
if-no-files-found: error
build-node-cli:
needs: version
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
@@ -522,7 +468,6 @@ jobs:
needs:
- version
- build-cli
- sign-cli-macos
- build-node-cli
- sign-cli-windows
- build-electron
@@ -0,0 +1,68 @@
---
name: ideal-pseudocode
description: Function-by-function refactoring loop driven by ideal pseudocode. Use when the user says "ideal pseudocode", asks to make a function read like its pseudocode, or wants a dense module cleaned up one function at a time.
---
# Ideal Pseudocode
Clean up one function at a time by writing the pseudocode it _should_ read as, naming every delta between that and the real code, and closing only the gaps the user approves.
## Loop
One function per round. Never touch code before the user picks a direction.
1. **Pick the target** with the user — usually the next function up or down the call chain from the last round.
2. **Read the current code** fresh from disk. It may have unsaved or parallel edits; ask before overwriting anything unexpected.
3. **Distill.** Write the function's ideal pseudocode in a `ts`-fenced code block — TypeScript-flavored for syntax highlighting, but pseudocode: comments over mechanics, one line per idea, every arm of a loop visible as an arm. For a dense or unfamiliar function, first show the _current_ structure as pseudocode, then the ideal.
4. **Name the deltas.** A numbered list; each delta is one concrete gap: control flow smuggled through combinators, a flag-typed parameter, colliding names, side-effect plumbing drowning a loop body, twin functions differing only by a filter. For each, give an honest cost/benefit and a decisive recommendation — including "leave it" when the density has reasons (state the reasons).
5. **Verify before proposing.** Any API the proposal builds on (a combinator, schedule, matcher) gets checked against the installed version's source, not memory.
6. **Get buy-in, then apply.** Minimal edits. Quarantine irreducible ugliness inside a small helper whose name is the pseudocode line it implements. Collapse shallow sibling functions into one deep function only when the distinguishing argument is a domain noun, not a behavior switch.
7. **Validate.** Typecheck and the focused test suites from the package directory; rerun timing-sensitive tests repeatedly. Flag any semantic change the restructure introduced, even when tests pass.
8. **Offer the next target** and stop.
## Style
- Keep each round tight: pseudocode block, deltas, recommendation, one question.
- State mutation belongs in one visible place (usually the loop tail), never spread across error channels or callback closures.
- Prefer early return and `if` chains for control flow; matchers are for producing values.
- Compare names against the question each caller is actually asking; rename toward the domain noun.
## Example round
Every round should read like this (abridged from a real one, on an Effect step-runner):
> `runStep` it is. The ideal pseudocode:
>
> ```ts
> // One logical step: one settled model call, however many attempts it takes.
> // An attempt can end without settling in two ways:
> // transient provider failure -> retry the same call, same assistant message
> // compaction rewrote history -> rebuild the request and call again
> function runStep(session, promotable, step) {
> while (true) {
> const result = callModel(session, promotable, step)
>
> if (result.completed) return result
>
> if (result.retryable) {
> wait(retrySchedule) // exhausted -> publish Step.Failed, fail drain
> promotable = none // never re-promote on a second attempt
> continue
> }
>
> // compaction restarted the step: fresh request from rewritten history
> promotable = none
> step = result.step
> }
> }
> ```
>
> Comparing against the real thing, three deltas:
>
> 1. **The retry arm doesn't live in the loop.** In the pseudocode, retry is just another loop arm. In the real code it's smuggled through the error channel: `Effect.tapError` mutating loop variables via `Effect.sync` closures, then `Effect.retryOrElse` re-running a suspended call. Four mutable variables mutated from three different places — the state flow is invisible unless you already know the trick.
> 2. **`recoverOverflow: typeof compaction.compact | undefined`** — a function-typed parameter used as a one-shot armed/disarmed flag. The callee already closes over `compaction`; a boolean says what it is.
> 3. **`assistantMessageID`** exists solely to resume the same durable message across retries — fine, but only legible once the retry arm is visible in the loop.
>
> The unifying move for (1): a schedule driver, so a retryable failure becomes a caught value handled in the loop body exactly like the compaction arm. One honest trade: it's more manual than the blessed combinator — but the manual version is the loop the combinator was hiding, and the loop already exists for restarts. Two restart mechanisms, one control structure.
>
> Want me to apply it — unified loop, simplified schedule input, boolean `recoverOverflow`?
@@ -348,10 +348,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
continue
}
}
const previous = messages.at(-1)
if (previous?.role === "user")
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] }
else messages.push({ role: "user", content })
messages.push({ role: "user", content })
continue
}
@@ -395,10 +392,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
const cachePoint = BedrockCache.block(breakpoints, part.cache)
if (cachePoint) content.push(cachePoint)
}
const previous = messages.at(-1)
if (previous?.role === "user")
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] }
else messages.push({ role: "user", content })
messages.push({ role: "user", content })
}
return messages
@@ -1,36 +0,0 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:bedrock-converse",
"provider:amazon-bedrock",
"protocol:bedrock-converse",
"tool",
"tool-loop",
"parallel"
],
"name": "bedrock-converse/continues-after-parallel-tool-results",
"recordedAt": "2026-08-11T16:41:46.482Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-micro-v1%3A0/converse-stream",
"headers": {
"content-type": "application/json"
},
"body": "{\"modelId\":\"us.amazon.nova-micro-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"Compare the weather in Paris and London.\"}]},{\"role\":\"assistant\",\"content\":[{\"toolUse\":{\"toolUseId\":\"weather_paris\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}},{\"toolUse\":{\"toolUseId\":\"weather_london\",\"name\":\"get_weather\",\"input\":{\"city\":\"London\"}}}]},{\"role\":\"user\",\"content\":[{\"toolResult\":{\"toolUseId\":\"weather_paris\",\"content\":[{\"json\":{\"temperature\":22,\"condition\":\"sunny\"}}],\"status\":\"success\"}},{\"toolResult\":{\"toolUseId\":\"weather_london\",\"content\":[{\"json\":{\"temperature\":14,\"condition\":\"rainy\"}}],\"status\":\"success\"}}]}],\"system\":[{\"text\":\"After receiving both tool results, reply exactly: Paris is sunny; London is rainy.\"}],\"inferenceConfig\":{\"maxTokens\":40,\"temperature\":0},\"toolConfig\":{\"tools\":[{\"toolSpec\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"inputSchema\":{\"json\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}}]}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/vnd.amazon.eventstream"
},
"body": "AAAAqAAAAFKgEDvmCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFEiLCJyb2xlIjoiYXNzaXN0YW50In189ig4AAAAzgAAAFfGCE2ECzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IlBhcmlzIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVVYifWttETIAAADDAAAAVz6YiTULOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIGlzIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE0ifRHJ8Q0AAACqAAAAV6q6nAkLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIHN1bm55In0sInAiOiJhYmNkZWZnaGlqayJ98ZCy6gAAALAAAABXgOoTKgs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiI7In0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2In020bBKAAAAygAAAFcziOtECzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiBMb25kb24ifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUCJ9ew04hAAAAK4AAABXXzo6yQs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgaXMifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxciJ9yK3bdAAAALcAAABXMsrPOgs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgcmFpbnkifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eCJ9JoCYVwAAALoAAABXyloLiws6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIuIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRiJ9WJwR8wAAAMEAAABXRFjaVQs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU4ifYzp4V0AAAChAAAAVqptnY4LOmV2ZW50LXR5cGUHABBjb250ZW50QmxvY2tTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQyJ9AHyeLwAAAJUAAABRYKgWaws6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0Iiwic3RvcFJlYXNvbiI6ImVuZF90dXJuIn2HCXz0AAABBgAAAE6wWpX7CzpldmVudC10eXBlBwAIbWV0YWRhdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJtZXRyaWNzIjp7ImxhdGVuY3lNcyI6MTA5MH0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVSIsInVzYWdlIjp7ImlucHV0VG9rZW5zIjo1MjEsIm91dHB1dFRva2VucyI6OSwic2VydmVyVG9vbFVzYWdlIjp7fSwidG90YWxUb2tlbnMiOjUzMH19Uwfxiw==",
"bodyEncoding": "base64"
}
}
]
}
@@ -255,57 +255,6 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("merges parallel tool results into one user message", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
id: "req_parallel_history",
model,
messages: [
Message.user("Compare the weather."),
Message.assistant([
ToolCallPart.make({ id: "tool_paris", name: "lookup", input: { city: "Paris" } }),
ToolCallPart.make({ id: "tool_london", name: "lookup", input: { city: "London" } }),
]),
Message.tool({ id: "tool_paris", name: "lookup", result: { forecast: "sunny" } }),
Message.tool({ id: "tool_london", name: "lookup", result: { forecast: "rainy" } }),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: [{ text: "Compare the weather." }] },
{
role: "assistant",
content: [
{ toolUse: { toolUseId: "tool_paris", name: "lookup", input: { city: "Paris" } } },
{ toolUse: { toolUseId: "tool_london", name: "lookup", input: { city: "London" } } },
],
},
{
role: "user",
content: [
{
toolResult: {
toolUseId: "tool_paris",
content: [{ json: { forecast: "sunny" } }],
status: "success",
},
},
{
toolResult: {
toolUseId: "tool_london",
content: [{ json: { forecast: "rainy" } }],
status: "success",
},
},
],
},
])
}),
)
it.effect("lowers image content in tool-result messages", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1216,39 +1165,4 @@ describe("Bedrock Converse recorded", () => {
)
}),
)
recorded.effect.with("continues after parallel tool results", { tags: ["tool", "tool-loop", "parallel"] }, () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
id: "recorded_bedrock_parallel_tool_results",
model: recordedModel(),
system: "After receiving both tool results, reply exactly: Paris is sunny; London is rainy.",
messages: [
Message.user("Compare the weather in Paris and London."),
Message.assistant([
ToolCallPart.make({ id: "weather_paris", name: weatherToolName, input: { city: "Paris" } }),
ToolCallPart.make({ id: "weather_london", name: weatherToolName, input: { city: "London" } }),
]),
Message.tool({
id: "weather_paris",
name: weatherToolName,
result: { temperature: 22, condition: "sunny" },
}),
Message.tool({
id: "weather_london",
name: weatherToolName,
result: { temperature: 14, condition: "rainy" },
}),
],
tools: [weatherTool],
cache: "none",
generation: { maxTokens: 40, temperature: 0 },
}),
)
expect(response.text.trim()).toBe("Paris is sunny; London is rainy.")
expect(response.finishReason?.normalized).toBe("stop")
}),
)
})
@@ -3,7 +3,6 @@ import type { UpdaterState } from "@/updater"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { showToast } from "@/utils/toast"
import { formatServerError } from "@/utils/server-errors"
export function updaterAction(state: UpdaterState | undefined) {
if (!state) return { label: "settings.updates.action.checkNow" as const }
@@ -32,14 +31,7 @@ export function useUpdaterAction() {
action,
async run() {
const run = action().run
if (run === "install") {
return platform.updater?.install().catch((error) => {
showToast({
title: language.t("common.requestFailed"),
description: formatServerError(error, language.t, language.t("common.requestFailed")),
})
})
}
if (run === "install") return platform.updater?.install()
if (run !== "check") return
const state = await platform.updater?.check()
@@ -69,63 +69,6 @@ describe("v2 session reducer", () => {
})
})
test("prefers durable selection predecessors and derives them for older events", () => {
const source: SessionMessageInfo[] = [
{ id: "msg_previous_agent", type: "agent-switched", agent: "build", time: { created: 1 } },
{
id: "msg_previous_model",
type: "model-switched",
model: { id: "old", providerID: "provider" },
time: { created: 1 },
},
]
const reducer = createV2SessionReducer()
const agent = reducer.reduce(
source,
event({
...base,
id: "evt_agent",
type: "session.agent.selected",
data: { sessionID: "ses_1", agent: "plan", previous: "review" },
}),
)
const model = reducer.reduce(
source,
event({
...base,
id: "evt_model",
type: "session.model.selected",
data: {
sessionID: "ses_1",
model: { id: "new", providerID: "provider" },
previous: { id: "durable", providerID: "provider" },
},
}),
)
const legacyAgent = reducer.reduce(
source,
event({
...base,
id: "evt_legacy_agent",
type: "session.agent.selected",
data: { sessionID: "ses_1", agent: "plan" },
}),
)
expect(agent?.messages.at(-1)).toMatchObject({ type: "agent-switched", agent: "plan", previous: "review" })
expect(model?.messages.at(-1)).toMatchObject({
type: "model-switched",
model: { id: "new" },
previous: { id: "durable" },
})
expect(legacyAgent?.messages.at(-1)).toMatchObject({
type: "agent-switched",
agent: "plan",
previous: "build",
})
})
test("folds tool, retry, and completion events", () => {
const reducer = createV2SessionReducer()
let messages: SessionMessageInfo[] = []
@@ -61,12 +61,6 @@ export function createV2SessionReducer() {
type: "agent-switched",
metadata: event.metadata,
agent: event.data.agent,
previous:
event.data.previous ??
source.findLast(
(item): item is Extract<SessionMessageInfo, { type: "agent-switched" | "assistant" }> =>
item.type === "agent-switched" || item.type === "assistant",
)?.agent,
time: { created: event.created },
})
case "session.model.selected":
@@ -75,12 +69,10 @@ export function createV2SessionReducer() {
type: "model-switched",
metadata: event.metadata,
model: event.data.model,
previous:
event.data.previous ??
source.findLast(
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
item.type === "model-switched" || item.type === "assistant",
)?.model,
previous: source.findLast(
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
item.type === "model-switched" || item.type === "assistant",
)?.model,
time: { created: event.created },
})
case "session.synthetic":
-41
View File
@@ -1,41 +0,0 @@
import { $ } from "bun"
import { readdir } from "node:fs/promises"
import path from "node:path"
import { brotliCompressSync, constants } from "node:zlib"
export async function buildAppArchive(channel: string) {
const root = path.resolve(import.meta.dirname, "../../app")
await $`bun run build`.cwd(root).env({ ...process.env, OPENCODE_CHANNEL: channel })
const assets = Object.fromEntries(
await Promise.all(
(await files(path.join(root, "dist")))
.filter((key) => !key.endsWith(".map"))
.map(async (key) => {
const source = path.join(root, "dist", key)
const body = Buffer.from(await Bun.file(source).arrayBuffer())
const encoding = isText(key) ? "utf8" : "base64"
return [key, { encoding, content: body.toString(encoding) }] as const
}),
),
)
return brotliCompressSync(JSON.stringify(assets), {
params: { [constants.BROTLI_PARAM_QUALITY]: 11 },
}).toString("base64")
}
function isText(key: string) {
return key === "_headers" || /\.(?:css|html|js|json|svg|txt|webmanifest|xml)$/.test(key)
}
async function files(root: string, current = root): Promise<string[]> {
return (
await Promise.all(
(await readdir(current, { withFileTypes: true })).map((entry) => {
const target = path.join(current, entry.name)
return entry.isDirectory() ? files(root, target) : [path.relative(root, target).replaceAll(path.sep, "/")]
}),
)
)
.flat()
.toSorted()
}
+1 -10
View File
@@ -12,7 +12,6 @@ import { modelsData } from "./generate"
import { collectNodeAssets, copyNodeAssets, hashNodeAssets, seaAssetMap } from "./node-assets"
import { mainConfig } from "../vite.node.config"
import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target"
import { buildAppArchive } from "./app-assets"
const NODE_VERSION = "26.4.0"
const dir = path.resolve(import.meta.dirname, "..")
@@ -56,21 +55,13 @@ const builder =
!bundleOnly || targets.some((target) => target.platform === process.platform && target.arch === process.arch)
? await resolveHostNode()
: undefined
const appArchive = await buildAppArchive(Script.channel)
for (const target of targets) {
console.log(`building cli-node-${targetName(target)}`)
const assets = await collectNodeAssets(target)
await rm("dist-node", { recursive: true, force: true })
const assetHash = await hashNodeAssets(assets)
const input = {
version: Script.version,
channel: Script.channel,
models: modelsData,
assetHash,
target,
appArchive,
}
const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target }
await copyNodeAssets(assets)
await build(mainConfig(input))
+1 -16
View File
@@ -8,7 +8,6 @@ import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import type { BunPlugin } from "bun"
import pkg from "../package.json"
import { modelsData } from "./generate"
import { buildAppArchive } from "./app-assets"
const dir = path.resolve(import.meta.dirname, "..")
const binary = "opencode2"
@@ -55,20 +54,6 @@ const targets = singleFlag
: allTargets
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
const appArchive = await buildAppArchive(Script.channel)
const appAssetsPlugin: BunPlugin = {
name: "opencode-app-assets",
setup(build) {
build.onResolve({ filter: /^virtual:opencode-app-assets$/ }, () => ({
path: "opencode-app-assets",
namespace: "opencode",
}))
build.onLoad({ filter: /^opencode-app-assets$/, namespace: "opencode" }, () => ({
loader: "js",
contents: `export default ${JSON.stringify(appArchive)}`,
}))
},
}
for (const item of targets) {
const parcelWatcherPackage = `@parcel/watcher-${item.os}-${item.arch}${item.os === "linux" ? `-${item.abi ?? "glibc"}` : ""}`
@@ -95,7 +80,7 @@ for (const item of targets) {
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin],
plugins: [solidPlugin, parcelWatcherPlugin],
external: ["node-gyp"],
format: "esm",
minify: true,
-16
View File
@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-executable-page-protection</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+2 -1
View File
@@ -422,7 +422,8 @@ async function loadCatalog(client: OpenCodeClient, cwd: string): Promise<Catalog
defaultModel: {
providerID: defaultModel.providerID,
id: defaultModel.id,
variant: defaultModel.variants.find((variant) => variant.id === "default")?.id,
variant:
defaultModel.variants.find((variant) => variant.id === "default")?.id ?? defaultModel.variants[0]?.id,
},
modes: agents.map((agent) => ({ id: agent.id, name: agent.name, description: agent.description })),
defaultModeID: defaultAgent.id,
-51
View File
@@ -1,51 +0,0 @@
import { Effect, FileSystem, Option } from "effect"
import path from "node:path"
import { brotliDecompressSync } from "node:zlib"
import { OPENCODE_LOCAL } from "./version"
export type AssetMap = Readonly<Record<string, string | Uint8Array>>
type EncodedAssetMap = Readonly<Record<string, { readonly content: string; readonly encoding: "utf8" | "base64" }>>
export const load = Effect.fn("cli.app-assets.load")(function* () {
const embedded = yield* Effect.tryPromise(() => import("virtual:opencode-app-assets")).pipe(Effect.option)
if (Option.isSome(embedded) && embedded.value.default.length > 0) return decodeArchive(embedded.value.default)
if (!OPENCODE_LOCAL) return yield* Effect.fail(new Error("Web UI assets are missing from the CLI build"))
return decode(yield* sourceAssets())
})
function decodeArchive(archive: string) {
const body = brotliDecompressSync(Buffer.from(archive, "base64")).toString()
return decode(JSON.parse(body) as EncodedAssetMap)
}
const sourceAssets = Effect.fnUntraced(function* () {
const fs = yield* FileSystem.FileSystem
const root = path.resolve(import.meta.dirname, "../../app/dist")
const files = yield* fs.readDirectory(root, { recursive: true })
return Object.fromEntries(
(yield* Effect.forEach(
files.filter((file) => !file.endsWith(".map")),
Effect.fnUntraced(function* (file) {
const target = path.join(root, file)
if ((yield* fs.stat(target)).type === "Directory") return
const body = Buffer.from(yield* fs.readFile(target))
const encoding = isText(file) ? "utf8" : "base64"
return [file, { encoding, content: body.toString(encoding) }] as const
}),
{ concurrency: "unbounded" },
)).filter((asset) => asset !== undefined),
)
})
function decode(assets: EncodedAssetMap): AssetMap {
return Object.fromEntries(
Object.entries(assets).map(([key, asset]) => [
key,
asset.encoding === "utf8" ? asset.content : Buffer.from(asset.content, "base64"),
]),
)
}
function isText(file: string) {
return file === "_headers" || /\.(?:css|html|js|json|svg|txt|webmanifest|xml)$/.test(file)
}
+1 -1
View File
@@ -267,7 +267,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
}),
Spec.make("pair", { description: "Show server pairing information" }),
Spec.make("serve", {
description: "Start the v2 API and web server",
description: "Start the v2 API server",
params: {
hostname: Flag.string("hostname").pipe(Flag.optional),
port: Flag.integer("port").pipe(Flag.optional),
+7 -10
View File
@@ -13,7 +13,6 @@ import { HttpServer } from "effect/unstable/http"
import { Env } from "./env"
import { ServiceConfig } from "./services/service-config"
import { Updater } from "./services/updater"
import { WebUi } from "./services/web-ui"
export type Mode = "default" | "service" | "stdio"
@@ -44,16 +43,16 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
return yield* Effect.scoped(
Effect.gen(function* () {
const foreground = options.mode === "default"
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
const hostname = options.hostname ?? config.hostname ?? "127.0.0.1"
const port = options.port ?? config.port ?? (options.mode === "service" ? ServiceConfig.defaultPort() : undefined)
const incumbent =
serviceOptions !== undefined && port !== undefined
? yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
: undefined
if (incumbent !== undefined) return
if (
serviceOptions !== undefined &&
port !== undefined &&
(yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })) !== undefined
)
return
const { start } = yield* Effect.promise(() => import("@opencode-ai/server/process"))
const environmentPassword = yield* Env.password
// Keep the lease credential out of the environment inherited by tools.
@@ -69,7 +68,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
: randomBytes(32).toString("base64url")
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const instanceID = randomUUID()
const transform = yield* WebUi.handler()
const server = yield* start(
{
app: {
@@ -124,7 +122,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
return yield* register(address, password, instanceID, serviceOptions.file, shutdown)
}),
},
transform,
).pipe(
Effect.catch((error) => {
if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error)
@@ -146,7 +143,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
if (server === undefined) return
const url = HttpServer.formatAddress(server.address)
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
if (foreground && !environmentPassword) console.log(`server password ${password}`)
if (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`)
const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped)
return yield* options.mode === "service"
-63
View File
@@ -1,63 +0,0 @@
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Effect, FileSystem } from "effect"
import { HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { createHash } from "node:crypto"
import { load, type AssetMap } from "../app-assets"
export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { readonly assets?: AssetMap }) {
const fileSystem = yield* FileSystem.FileSystem
const assets = options?.assets
? Effect.succeed(options.assets)
: yield* Effect.cached(load().pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)))
return <E, R>(api: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>) =>
api.pipe(
Effect.catchIf(isRouteNotFound, () =>
HttpServerRequest.HttpServerRequest.pipe(
Effect.flatMap((request) => {
const url = new URL(request.url, "http://localhost")
if (url.pathname === "/api" || url.pathname.startsWith("/api/"))
return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
return assets.pipe(Effect.flatMap((files) => serveUI(request, url, files)))
}),
),
),
)
})
function serveUI(request: HttpServerRequest.HttpServerRequest, url: URL, assets: AssetMap) {
const key = url.pathname.replace(/^\//, "")
const name = assets[key] !== undefined ? key : "index.html"
const file = assets[name]
if (!file) return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
if (request.method !== "GET" && request.method !== "HEAD")
return Effect.succeed(HttpServerResponse.empty({ status: 405 }))
const html = name === "index.html"
const headers = {
"content-type": FSUtil.mimeType(name),
"cache-control": html ? "no-cache" : "public, max-age=31536000, immutable",
"content-security-policy": html
? cspForHtml(typeof file === "string" ? file : Buffer.from(file).toString())
: csp(),
"x-content-type-options": "nosniff",
}
return Effect.succeed(
request.method === "HEAD" ? HttpServerResponse.empty({ headers }) : HttpServerResponse.raw(file, { headers }),
)
}
function isRouteNotFound(error: unknown) {
return error instanceof HttpServerError.HttpServerError && error.reason._tag === "RouteNotFound"
}
function csp(hash = "") {
return `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; media-src 'self' data:; connect-src * data: blob:`
}
function cspForHtml(body: string) {
const match = body.match(
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(["'])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
)
return csp(match ? createHash("sha256").update(match[2]).digest("base64") : "")
}
export * as WebUi from "./web-ui"
-4
View File
@@ -1,4 +0,0 @@
declare module "virtual:opencode-app-assets" {
const archive: string
export default archive
}
@@ -3,38 +3,6 @@ import type { SessionConfigOption } from "@agentclientprotocol/sdk"
import { makeACPFixture, makeSession, secondModel } from "./service-fixture"
describe("acp service lifecycle", () => {
test("does not persist the first catalog variant when no explicit default exists", async () => {
const model = { ...secondModel, variants: [{ id: "none" }, { id: "high" }] }
await using fixture = makeACPFixture({
models: [model],
defaultModel: model,
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({
data: makeSession("ses_default_variant", {
model: { providerID: model.providerID, id: model.id },
}),
})
}
return undefined
},
})
const created = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
expect(fixture.requests).toContainEqual({
method: "POST",
path: "/api/session",
query: {},
body: {
location: { directory: "/workspace" },
agent: "build",
model: { providerID: "test", id: "second-model" },
},
})
expect(currentValue(created, "effort")).toBe("none")
})
test("loads and forks with paginated replay while resume does not replay", async () => {
await using fixture = makeACPFixture({
fetch(request) {
-70
View File
@@ -1,70 +0,0 @@
import { NodeFileSystem, NodeHttpServer } from "@effect/platform-node"
import { afterAll, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { HttpServer, HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { createServer } from "node:http"
import { mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { WebUi } from "../src/services/web-ui"
const root = await mkdtemp(path.join(tmpdir(), "opencode-web-ui-"))
afterAll(() => rm(root, { recursive: true, force: true }))
describe("web UI", () => {
test("falls back from API routes to assets and the SPA index", async () => {
const index = path.join(root, "index.html")
const asset = path.join(root, "app.js")
await writeFile(index, "<html><body>embedded</body></html>")
await writeFile(asset, "console.log('embedded')")
const assets = {
"index.html": await Bun.file(index).text(),
"app.js": await Bun.file(asset).text(),
"font.woff2": new Uint8Array([0, 1, 2, 255]),
}
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const transform = yield* WebUi.handler({ assets })
const http = yield* NodeHttpServer.make(createServer, { host: "127.0.0.1", port: 0 })
yield* http.serve(
transform(
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const pathname = new URL(request.url, "http://localhost").pathname
if (pathname === "/api/health") return HttpServerResponse.jsonUnsafe({ healthy: true })
return yield* Effect.fail(
new HttpServerError.HttpServerError({
reason: new HttpServerError.RouteNotFound({ request }),
}),
)
}),
),
)
const origin = HttpServer.formatAddress(http.address)
const health = yield* Effect.promise(() => fetch(`${origin}/api/health`))
expect(yield* Effect.promise(() => health.json())).toEqual({ healthy: true })
const missing = yield* Effect.promise(() => fetch(`${origin}/api/missing`))
expect(missing.status).toBe(404)
expect(yield* Effect.promise(() => missing.text())).toBe("")
const script = yield* Effect.promise(() => fetch(`${origin}/app.js`))
expect(yield* Effect.promise(() => script.text())).toBe("console.log('embedded')")
const font = yield* Effect.promise(() => fetch(`${origin}/font.woff2`))
expect(new Uint8Array(yield* Effect.promise(() => font.arrayBuffer()))).toEqual(
new Uint8Array([0, 1, 2, 255]),
)
const fallback = yield* Effect.promise(() => fetch(`${origin}/workspace/example`))
expect(yield* Effect.promise(() => fallback.text())).toContain("embedded")
expect(fallback.headers.get("content-security-policy")).toContain("default-src 'self'")
expect(fallback.headers.get("content-security-policy")).toContain("connect-src * data: blob:")
}),
).pipe(Effect.provide(NodeFileSystem.layer)),
)
})
})
-16
View File
@@ -17,19 +17,6 @@ function rawTextPlugin(): Plugin {
}
}
function appAssetsPlugin(archive: string): Plugin {
return {
name: "opencode:app-assets",
resolveId(id) {
if (id === "virtual:opencode-app-assets") return "\0virtual:opencode-app-assets"
},
load(id) {
if (id !== "\0virtual:opencode-app-assets") return
return `export default ${JSON.stringify(archive)}`
},
}
}
function runtimeRequirePlugin(): Plugin {
return {
name: "opencode:runtime-require",
@@ -225,14 +212,12 @@ export type NodeBuildInput = {
readonly models: string
readonly assetHash: string
readonly target: NodeTarget
readonly appArchive: string
}
export function mainConfig(input: NodeBuildInput): UserConfig {
return defineConfig({
root: dir,
plugins: [
appAssetsPlugin(input.appArchive),
rawTextPlugin(),
runtimeRequirePlugin(),
fffNodePlugin(),
@@ -274,5 +259,4 @@ export default mainConfig({
models: "undefined",
assetHash: "local",
target: nodeTarget(process.platform, process.arch),
appArchive: "",
})
+2 -10
View File
@@ -339,11 +339,7 @@ export type Endpoint5_31Output =
readonly type: "session.agent.selected"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly previous?: Agent.ID | undefined
}
readonly data: { readonly sessionID: Session.ID; readonly agent: Agent.ID }
}
| {
readonly id: Event.ID
@@ -352,11 +348,7 @@ export type Endpoint5_31Output =
readonly type: "session.model.selected"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly model: Model.Ref
readonly previous?: Model.Ref | undefined
}
readonly data: { readonly sessionID: Session.ID; readonly model: Model.Ref }
}
| {
readonly id: Event.ID
@@ -1740,7 +1740,7 @@ export function make(options: ClientOptions) {
request<ProjectCopyCreateOutput>(
{
method: "POST",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
query: { location: input["location"] },
body: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
successStatus: 200,
@@ -1753,7 +1753,7 @@ export function make(options: ClientOptions) {
request<ProjectCopyRemoveOutput>(
{
method: "DELETE",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
query: { location: input["location"] },
body: { directory: input["directory"], force: input["force"] },
successStatus: 204,
@@ -1766,7 +1766,7 @@ export function make(options: ClientOptions) {
request<ProjectCopyRefreshOutput>(
{
method: "POST",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`,
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401],
@@ -436,7 +436,7 @@ export type SessionAgentSelected = {
type: "session.agent.selected"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; agent: string; previous?: string }
data: { sessionID: string; agent: string }
}
export type SessionModelSelected = {
@@ -446,7 +446,7 @@ export type SessionModelSelected = {
type: "session.model.selected"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; model: ModelRef; previous?: ModelRef }
data: { sessionID: string; model: ModelRef }
}
export type SessionMoved = {
+2 -2
View File
@@ -9310,7 +9310,7 @@
"summary": "List references"
}
},
"/api/experimental/project/{projectID}/copy": {
"/experimental/project/{projectID}/copy": {
"post": {
"tags": ["projectCopy"],
"operationId": "v2.projectCopy.create",
@@ -9536,7 +9536,7 @@
}
}
},
"/api/experimental/project/{projectID}/copy/refresh": {
"/experimental/project/{projectID}/copy/refresh": {
"post": {
"tags": ["projectCopy"],
"operationId": "v2.projectCopy.refresh",
+1 -2
View File
@@ -25,10 +25,9 @@
},
"imports": {
"#sqlite": {
"workerd": "./src/database/sqlite.workerd.ts",
"bun": "./src/database/sqlite.bun.ts",
"node": "./src/database/sqlite.node.ts",
"default": "./src/database/sqlite.node.ts"
"default": "./src/database/sqlite.bun.ts"
},
"#pty": {
"bun": "./src/pty/pty.bun.ts",
+2 -12
View File
@@ -1,8 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "00924d88-1842-4d71-ac74-5682ddc47e1c",
"prevIds": ["15060ec5-05f7-4b86-b2a5-9108609432b3"],
"id": "15060ec5-05f7-4b86-b2a5-9108609432b3",
"prevIds": ["1551a157-8959-4ba9-a52b-4ea3b7b28cae"],
"ddl": [
{
"name": "account_state",
@@ -1302,16 +1302,6 @@
"entityType": "columns",
"table": "session_v2"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "resume_attempts",
"entityType": "columns",
"table": "session_v2"
},
{
"type": "text",
"notNull": false,
+5 -4
View File
@@ -194,10 +194,11 @@ async function formatTypescript(input: string) {
function renderRegistry(names: string[]) {
return `import type { DatabaseMigration } from "./migration"
${names.map((name, index) => `import m${index.toString().padStart(2, "0")} from "./migration/${name}"`).join("\n")}
export const migrations = [
${names.map((_, index) => ` m${index.toString().padStart(2, "0")},`).join("\n")}
] satisfies DatabaseMigration.Migration[]
export const migrations: DatabaseMigration.Migration[] = (
await Promise.all([
${names.map((name) => ` import("./migration/${name}"),`).join("\n")}
])
).map((module) => module.default)
`
}
+8 -36
View File
@@ -51,27 +51,6 @@ export function map(input: MapInput): Mapping | undefined {
...mapGoogleOptions(input.settings),
},
}
case "@ai-sdk/google-vertex/anthropic":
return {
package: "@opencode-ai/ai/providers/google-vertex/messages",
settings: {
...baseSettings,
...(typeof input.settings.accessToken === "string" ? { accessToken: input.settings.accessToken } : {}),
...(typeof input.settings.location === "string" ? { location: input.settings.location } : {}),
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
...(isRecord(input.settings.thinking) || typeof input.settings.effort === "string"
? {
providerOptions: {
anthropic: {
...(isRecord(input.settings.thinking) ? { thinking: input.settings.thinking } : {}),
...(typeof input.settings.effort === "string" ? { effort: input.settings.effort } : {}),
},
},
}
: {}),
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
case "@openrouter/ai-sdk-provider":
return mapOpenRouter(input.settings, baseSettings)
case "@ai-sdk/xai":
@@ -109,13 +88,9 @@ function mapBedrockSettings(
: typeof settings.bearerToken === "string"
? settings.bearerToken
: undefined
const region = bedrockRegion(settings)
const credentials = mapBedrockCredentials(settings, region)
const credentials = mapBedrockCredentials(settings)
return {
...baseSettings,
...(typeof baseSettings.baseURL === "string" && region !== undefined
? { baseURL: baseSettings.baseURL.replaceAll("${AWS_REGION}", region) }
: {}),
...(typeof settings.baseURL !== "string" && typeof settings.endpoint === "string"
? { baseURL: settings.endpoint }
: {}),
@@ -180,8 +155,14 @@ function mapBedrockRequest(input: MapInput): Pick<Mapping, "headers" | "body"> {
}
}
function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>, region: string | undefined) {
function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>) {
const credentials = isRecord(settings.credentials) ? settings.credentials : settings
const region =
typeof settings.region === "string"
? settings.region
: typeof credentials.region === "string"
? credentials.region
: undefined
if (
region === undefined ||
typeof credentials.accessKeyId !== "string" ||
@@ -196,15 +177,6 @@ function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>, regi
}
}
function bedrockRegion(settings: Readonly<Record<string, unknown>>) {
const credentials = isRecord(settings.credentials) ? settings.credentials : settings
return typeof settings.region === "string"
? settings.region
: typeof credentials.region === "string"
? credentials.region
: undefined
}
function mapOpenAIOptions(settings: Readonly<Record<string, unknown>>) {
const options = {
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
+2 -2
View File
@@ -204,13 +204,13 @@ export const layer = (options?: Options) =>
const claude = [
...new Set([
...((yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
...discovered.filter((item) => path.basename(item) === ".claude").toReversed(),
...discovered.filter((item) => path.basename(item) === ".claude"),
]),
].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) }))
const agents = [
...new Set([
...((yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
...discovered.filter((item) => path.basename(item) === ".agents").toReversed(),
...discovered.filter((item) => path.basename(item) === ".agents"),
]),
].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) }))
+69 -48
View File
@@ -83,14 +83,8 @@ export function normalize(input: unknown): Result {
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
if (legacyShare !== undefined) encoded.share = legacyShare
const legacyReferences = decodeMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics, decodeEncoded)
const nativeReferences = decodeMap(
input.references,
ConfigReference.Entry,
["references"],
diagnostics,
decodeEncoded,
)
const legacyReferences = decodeEncodedMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics)
const nativeReferences = decodeEncodedMap(input.references, ConfigReference.Entry, ["references"], diagnostics)
mergeMap(
encoded,
"references",
@@ -100,13 +94,13 @@ export function normalize(input: unknown): Result {
diagnostics,
)
const legacyCommands = decodeMap(input.command, ConfigCommandV1.Info, ["command"], diagnostics, decodeValue)
const legacyCommands = decodeMap(input.command, ConfigCommandV1.Info, ["command"], diagnostics)
diagnoseSelectionMap(input.command, ["command"], diagnostics)
const migratedCommands = mapValues(legacyCommands, (value) => {
const migrated = ConfigMigrateV1.commands({ value })?.value
return migrated === undefined ? undefined : canonical(ConfigCommand.Info, migrated)
})
const nativeCommands = decodeMap(input.commands, ConfigCommand.Info, ["commands"], diagnostics, decodeEncoded)
const nativeCommands = decodeEncodedMap(input.commands, ConfigCommand.Info, ["commands"], diagnostics)
mergeMap(
encoded,
"commands",
@@ -116,9 +110,8 @@ export function normalize(input: unknown): Result {
diagnostics,
)
const legacyAgents = mapValues(
decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics, decodeValue),
(value) => canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
const legacyAgents = mapValues(decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics), (value) =>
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
)
const legacySmallModel = own(input, "small_model")
? decodeValue(Schema.String, input.small_model, ["small_model"], diagnostics)
@@ -137,11 +130,11 @@ export function normalize(input: unknown): Result {
model: migratedSmallModel,
...legacyAgents.title,
}
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics, decodeValue), (value) =>
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics), (value) =>
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent({ ...value, mode: "primary" })),
)
const migratedAgents = mergeMaps(legacyAgents, modeAgents, ["agents"], diagnostics)
const nativeAgents = decodeMap(input.agents, ConfigAgent.Info, ["agents"], diagnostics, decodeEncoded)
const nativeAgents = decodeEncodedMap(input.agents, ConfigAgent.Info, ["agents"], diagnostics)
diagnoseAgentUnsupported(input.agent, ["agent"], diagnostics)
diagnoseAgentUnsupported(input.mode, ["mode"], diagnostics)
mergeMap(
@@ -154,7 +147,7 @@ export function normalize(input: unknown): Result {
)
const legacyProviders = migrateProviders(input.provider, diagnostics)
const nativeProviders = decodeMap(input.providers, ConfigProvider.Info, ["providers"], diagnostics, decodeEncoded)
const nativeProviders = decodeEncodedMap(input.providers, ConfigProvider.Info, ["providers"], diagnostics)
mergeMap(
encoded,
"providers",
@@ -166,14 +159,14 @@ export function normalize(input: unknown): Result {
const toolRules = migrateTools(input.tools, diagnostics)
const permissionRules = migratePermissions(input.permission, diagnostics)
const nativePermissions = decodeList(input.permissions, Permission.Rule, ["permissions"], diagnostics, decodeEncoded)
const nativePermissions = decodeEncodedList(input.permissions, Permission.Rule, ["permissions"], diagnostics)
const permissions = [...toolRules, ...permissionRules, ...nativePermissions]
if (permissions.length || Array.isArray(input.permissions)) encoded.permissions = permissions
const legacyPlugins = decodeList(input.plugin, ConfigPluginV1.Spec, ["plugin"], diagnostics, decodeValue).map(
(plugin) => (typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] }),
const legacyPlugins = decodeList(input.plugin, ConfigPluginV1.Spec, ["plugin"], diagnostics).map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
)
const nativePlugins = decodeList(input.plugins, ConfigPlugin.Plugin, ["plugins"], diagnostics, decodeEncoded)
const nativePlugins = decodeEncodedList(input.plugins, ConfigPlugin.Plugin, ["plugins"], diagnostics)
if (legacyPlugins.length || nativePlugins.length || Array.isArray(input.plugin) || Array.isArray(input.plugins))
encoded.plugins = [...legacyPlugins, ...nativePlugins]
@@ -207,7 +200,7 @@ export function normalize(input: unknown): Result {
overlay(encoded, key, value, [key], diagnostics)
})
const instructions = decodeList(input.instructions, Schema.String, ["instructions"], diagnostics, decodeEncoded)
const instructions = decodeEncodedList(input.instructions, Schema.String, ["instructions"], diagnostics)
if (instructions.length || Array.isArray(input.instructions)) encoded.instructions = instructions
return { type: "normalized", encoded, diagnostics }
@@ -216,7 +209,7 @@ export function normalize(input: unknown): Result {
function normalizeSkills(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
if (!own(input, "skills")) return
if (Array.isArray(input.skills)) {
encoded.skills = decodeList(input.skills, Schema.String, ["skills"], diagnostics, decodeEncoded)
encoded.skills = decodeEncodedList(input.skills, Schema.String, ["skills"], diagnostics)
return
}
if (!isRecord(input.skills)) {
@@ -224,8 +217,8 @@ function normalizeSkills(input: Record<string, unknown>, encoded: Record<string,
return
}
encoded.skills = [
...decodeList(input.skills.paths, Schema.String, ["skills", "paths"], diagnostics, decodeEncoded),
...decodeList(input.skills.urls, Schema.String, ["skills", "urls"], diagnostics, decodeEncoded),
...decodeEncodedList(input.skills.paths, Schema.String, ["skills", "paths"], diagnostics),
...decodeEncodedList(input.skills.urls, Schema.String, ["skills", "urls"], diagnostics),
]
}
@@ -255,8 +248,8 @@ function normalizeMcp(input: Record<string, unknown>, encoded: Record<string, un
return
}
if (name === "servers" && !isDirectLegacyMcp(value)) {
Object.entries(decodeMap(value, ConfigMCP.Server, path, diagnostics, decodeEncoded)).forEach(
([key, server]) => setOwn(nativeServers, key, server),
Object.entries(decodeEncodedMap(value, ConfigMCP.Server, path, diagnostics)).forEach(([key, server]) =>
setOwn(nativeServers, key, server),
)
return
}
@@ -411,13 +404,7 @@ function normalizeExperimental(
if (value !== undefined) result.subagent_depth = value
}
native.push(
...decodeList(
experimental.policies,
ConfigPolicy.Info,
["experimental", "policies"],
diagnostics,
decodeEncoded,
),
...decodeEncodedList(experimental.policies, ConfigPolicy.Info, ["experimental", "policies"], diagnostics),
)
}
}
@@ -433,7 +420,7 @@ function normalizeWatcher(input: Record<string, unknown>, encoded: Record<string
invalid(["watcher"], diagnostics)
return
}
const ignore = decodeList(input.watcher.ignore, Schema.String, ["watcher", "ignore"], diagnostics, decodeEncoded)
const ignore = decodeEncodedList(input.watcher.ignore, Schema.String, ["watcher", "ignore"], diagnostics)
encoded.watcher = ignore.length || Array.isArray(input.watcher.ignore) ? { ignore } : {}
}
@@ -448,7 +435,7 @@ function normalizeFormatter(
if (value !== undefined) encoded.formatter = value
return
}
const entries = decodeMap(input.formatter, ConfigFormatter.Entry, ["formatter"], diagnostics, decodeEncoded)
const entries = decodeEncodedMap(input.formatter, ConfigFormatter.Entry, ["formatter"], diagnostics)
if (isRecord(input.formatter) && (!Object.keys(input.formatter).length || Object.keys(entries).length))
encoded.formatter = entries
}
@@ -460,7 +447,7 @@ function normalizeLsp(input: Record<string, unknown>, encoded: Record<string, un
if (value !== undefined) encoded.lsp = value
return
}
const entries = decodeMap(input.lsp, ConfigLSP.Entry, ["lsp"], diagnostics, decodeEncoded)
const entries = decodeEncodedMap(input.lsp, ConfigLSP.Entry, ["lsp"], diagnostics)
if (isRecord(input.lsp) && (!Object.keys(input.lsp).length || Object.keys(entries).length)) encoded.lsp = entries
}
@@ -610,44 +597,78 @@ function decodeProviderList(
return {
present: true,
nonEmpty: input[key].length > 0,
values: decodeList(input[key], Schema.String, [key], diagnostics, decodeValue),
values: decodeList(input[key], Schema.String, [key], diagnostics),
}
}
function decodeMap<S extends Schema.Codec<unknown, unknown, never>, A>(
function decodeEncodedMap<S extends Schema.Codec<unknown, unknown, never, never>>(
value: unknown,
schema: S,
path: string[],
diagnostics: Diagnostic[],
decode: (schema: S, value: unknown, path: string[], diagnostics: Diagnostic[]) => A | undefined,
): Record<string, A> {
) {
if (value === undefined) return {}
if (!isRecord(value)) {
invalid(path, diagnostics)
return {}
}
return Object.fromEntries(
Object.entries(value).flatMap(([name, raw]): [string, A][] => {
const decoded = decode(schema, raw, [...path, name], diagnostics)
Object.entries(value).flatMap(([name, raw]) => {
const decoded = decodeEncoded(schema, raw, [...path, name], diagnostics)
return decoded === undefined ? [] : [[name, decoded]]
}),
)
}
function decodeList<S extends Schema.Codec<unknown, unknown, never>, A>(
function decodeMap<S extends Schema.Codec<unknown, unknown, never, never>>(
value: unknown,
schema: S,
path: string[],
diagnostics: Diagnostic[],
decode: (schema: S, value: unknown, path: string[], diagnostics: Diagnostic[]) => A | undefined,
): A[] {
if (value === undefined) return []
) {
if (value === undefined) return {} as Record<string, S["Type"]>
if (!isRecord(value)) {
invalid(path, diagnostics)
return {} as Record<string, S["Type"]>
}
return Object.fromEntries(
Object.entries(value).flatMap(([name, raw]) => {
const decoded = decodeValue(schema, raw, [...path, name], diagnostics)
return decoded === undefined ? [] : [[name, decoded]]
}),
) as Record<string, S["Type"]>
}
function decodeEncodedList<S extends Schema.Codec<unknown, unknown, never, never>>(
value: unknown,
schema: S,
path: string[],
diagnostics: Diagnostic[],
) {
if (value === undefined) return [] as S["Encoded"][]
if (!Array.isArray(value)) {
invalid(path, diagnostics)
return []
return [] as S["Encoded"][]
}
return value.flatMap((item, index) => {
const decoded = decode(schema, item, [...path, String(index)], diagnostics)
const decoded = decodeEncoded(schema, item, [...path, String(index)], diagnostics)
return decoded === undefined ? [] : [decoded]
})
}
function decodeList<S extends Schema.Codec<unknown, unknown, never, never>>(
value: unknown,
schema: S,
path: string[],
diagnostics: Diagnostic[],
) {
if (value === undefined) return [] as S["Type"][]
if (!Array.isArray(value)) {
invalid(path, diagnostics)
return [] as S["Type"][]
}
return value.flatMap((item, index) => {
const decoded = decodeValue(schema, item, [...path, String(index)], diagnostics)
return decoded === undefined ? [] : [decoded]
})
}
@@ -27,10 +27,8 @@ export const Plugin = define({
const changes = yield* PubSub.sliding<string>(1)
const lock = Semaphore.makeUnsafe(1)
const start = yield* fs.resolve(location.directory)
const root = yield* fs.resolve(location.project.directory)
const home = yield* fs.resolve(global.home)
const project = discovery.project && FSUtil.contains(root, start)
const stop = FSUtil.contains(home, start) ? home : root
const stop = yield* fs.resolve(location.project.directory)
const project = discovery.project && FSUtil.contains(stop, start)
const globalFile = yield* fs.resolve(join(global.config, "AGENTS.md"))
const loaded: { current: Loaded } = { current: { type: "available", files: [] } }
+12 -60
View File
@@ -4,7 +4,7 @@ import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Option, Predicate, PubSub, Schema, Scope, Stream } from "effect"
import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect"
import path from "path"
import { fileURLToPath } from "url"
import { Config } from "../../config"
@@ -153,70 +153,22 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
})
})
const sourceDirectories = ["plugin", "plugins"] as const
const Package = Schema.Struct({
exports: Schema.optional(Schema.Unknown),
module: Schema.optional(Schema.Unknown),
main: Schema.optional(Schema.Unknown),
})
const decodePackage = Schema.decodeUnknownOption(Package)
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const children = (yield* Effect.forEach(sourceDirectories, (source) =>
fs.readDirectoryEntries(path.join(directory, source)).pipe(
Effect.orElseSucceed(() => []),
Effect.map((entries) =>
entries.map((entry) => ({ ...entry, target: path.join(directory, source, entry.name) })),
),
),
))
.flat()
.sort((a, b) => (a.target < b.target ? -1 : a.target > b.target ? 1 : 0))
const targets = yield* Effect.forEach(children, (entry) => discoverChild(fs, entry))
return targets.flatMap(Option.toArray).map((target): Operation => ({ type: "add", target, options: {} }))
const files = yield* fs
.scan("{plugin,plugins}/*.{ts,js}", {
cwd: directory,
absolute: true,
include: "file",
dot: true,
symlink: true,
})
.pipe(Effect.orElseSucceed(() => []))
return files.sort().map((target): Operation => ({ type: "add", target, options: {} }))
})
}
function discoverChild(fs: FSUtil.Interface, entry: FSUtil.DirEntry & { target: string }) {
return Effect.gen(function* () {
const source = entry.target.endsWith(".ts") || entry.target.endsWith(".js")
if (entry.type === "file" && source) return Option.some(entry.target)
if (entry.type === "directory") return yield* discoverPackage(fs, entry.target)
if (entry.type !== "symlink") return Option.none<string>()
if (source && (yield* fs.isFile(entry.target))) return Option.some(entry.target)
if (yield* fs.isDir(entry.target)) return yield* discoverPackage(fs, entry.target)
return Option.none<string>()
})
}
function discoverPackage(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const root = yield* fs.resolve(directory)
const manifest = yield* fs
.readJson(path.join(directory, "package.json"))
.pipe(Effect.map(decodePackage), Effect.orElseSucceed(Option.none))
const configured = Option.isSome(manifest)
? [manifest.value.exports, manifest.value.module, manifest.value.main].filter(Predicate.isString)
: []
return yield* Effect.findFirst(
[...configured, "index.ts", "index.js"]
.filter((entry) => !path.isAbsolute(entry))
.map((entry) => path.resolve(directory, entry))
.filter((entry) => FSUtil.contains(directory, entry)),
(entry) =>
fs
.isFile(entry)
.pipe(
Effect.flatMap((exists) =>
exists
? fs.resolve(entry).pipe(Effect.map((resolved) => FSUtil.contains(root, resolved)))
: Effect.succeed(false),
),
),
)
})
}
const sourceDirectories = ["plugin", "plugins"] as const
function isPluginSource(entries: readonly Entry[], file: string) {
return entries.some(
+45 -1
View File
@@ -1,9 +1,12 @@
export * as Credential from "./credential"
import { asc, eq } from "drizzle-orm"
import { asc, eq, sql } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import fs from "fs/promises"
import path from "path"
import { Credential } from "@opencode-ai/schema/credential"
import { Integration } from "@opencode-ai/schema/integration"
import { Global } from "@opencode-ai/util/global"
import { Database } from "./database/database"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { CredentialTable } from "./credential/sql"
@@ -48,6 +51,47 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Credential") {}
export const importFromDatabase = Effect.fn("Credential.importFromDatabase")(function* (input: {
readonly path: string
}) {
const database = yield* Database.Service
const filename = path.isAbsolute(input.path) ? input.path : path.join(Global.Path.data, input.path)
yield* Effect.promise(() => fs.access(filename))
yield* database.db.run(sql`ATTACH DATABASE ${filename} AS credential_snapshot`).pipe(Effect.orDie)
yield* Effect.gen(function* () {
yield* database.db.run(sql`DELETE FROM credential`)
// Snapshot OAuth credentials are access-token-only so the embedded host never rotates the user's refresh token.
yield* database.db.run(sql`
INSERT INTO credential (
id,
integration_id,
label,
value,
connector_id,
method_id,
active,
time_created,
time_updated
)
SELECT
id,
integration_id,
label,
CASE
WHEN json_extract(value, '$.type') = 'oauth'
THEN json_set(value, '$.refresh', '', '$.expires', 8640000000000000)
ELSE value
END,
connector_id,
method_id,
active,
time_created,
time_updated
FROM credential_snapshot.credential
`)
}).pipe(Effect.orDie, Effect.ensuring(database.db.run(sql`DETACH DATABASE credential_snapshot`).pipe(Effect.orDie)))
})
const layer = Layer.effect(
Service,
Effect.gen(function* () {
+9 -19
View File
@@ -1,9 +1,8 @@
export * as Database from "./database"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { sqliteLayer, supportsForeignKeyToggle, supportsTuningPragmas } from "#sqlite"
import { sqliteLayer } from "#sqlite"
import { Context, Effect, Layer, Schema } from "effect"
import type { SqlClient } from "effect/unstable/sql"
import { Global } from "@opencode-ai/util/global"
import { isAbsolute, join } from "path"
import { DatabaseMigration } from "./migration"
@@ -28,15 +27,12 @@ const databaseLayer = Layer.effect(
Effect.gen(function* () {
const db = yield* makeDatabase
if (supportsTuningPragmas) {
yield* db.run("PRAGMA journal_mode = WAL")
yield* db.run("PRAGMA synchronous = NORMAL")
yield* db.run("PRAGMA busy_timeout = 5000")
yield* db.run("PRAGMA cache_size = -64000")
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
}
// Durable Object SQLite always enforces foreign keys and rejects the pragma.
if (supportsForeignKeyToggle) yield* db.run("PRAGMA foreign_keys = ON")
yield* db.run("PRAGMA journal_mode = WAL")
yield* db.run("PRAGMA synchronous = NORMAL")
yield* db.run("PRAGMA busy_timeout = 5000")
yield* db.run("PRAGMA cache_size = -64000")
yield* db.run("PRAGMA foreign_keys = ON")
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
yield* DatabaseMigration.apply(db)
return { db }
@@ -46,21 +42,15 @@ const databaseLayer = Layer.effect(
export function layer(options: Options = { path: ":memory:" }) {
return Layer.unwrap(
Effect.gen(function* () {
const provide = (filename: string) => layerFromClient.pipe(Layer.provide(sqliteLayer({ filename })))
const global = yield* Global.Service
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
const filename = options.path ?? ":memory:"
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
const global = yield* Global.Service
return provide(join(global.data, filename))
}),
)
}
// The database service over an injected SqlClient, for runtimes that receive
// database storage instead of opening a filesystem path. Any client provided
// here still goes through the pragma guards and migrations; Global is required
// because migrations may read it (the v1 import).
export const layerFromClient: Layer.Layer<Service, never, SqlClient.SqlClient | Global.Service> = databaseLayer
export function configured(options?: Options) {
return makeGlobalNode({ service: Service, layer: layer(options), deps: [Global.node] })
}
+45 -86
View File
@@ -1,88 +1,47 @@
import type { DatabaseMigration } from "./migration"
import m00 from "./migration/20260127222353_familiar_lady_ursula"
import m01 from "./migration/20260211171708_add_project_commands"
import m02 from "./migration/20260213144116_wakeful_the_professor"
import m03 from "./migration/20260225215848_workspace"
import m04 from "./migration/20260227213759_add_session_workspace_id"
import m05 from "./migration/20260228203230_blue_harpoon"
import m06 from "./migration/20260303231226_add_workspace_fields"
import m07 from "./migration/20260309230000_move_org_to_state"
import m08 from "./migration/20260312043431_session_message_cursor"
import m09 from "./migration/20260323234822_events"
import m10 from "./migration/20260410174513_workspace-name"
import m11 from "./migration/20260413175956_chief_energizer"
import m12 from "./migration/20260423070820_add_icon_url_override"
import m13 from "./migration/20260427172553_slow_nightmare"
import m14 from "./migration/20260428004200_add_session_path"
import m15 from "./migration/20260501142318_next_venus"
import m16 from "./migration/20260504145000_add_sync_owner"
import m17 from "./migration/20260507164347_add_workspace_time"
import m18 from "./migration/20260510033149_session_usage"
import m19 from "./migration/20260511000411_data_migration_state"
import m20 from "./migration/20260511173437_session-metadata"
import m21 from "./migration/20260601010001_normalize_storage_paths"
import m22 from "./migration/20260601202201_amazing_prowler"
import m23 from "./migration/20260602002951_lowly_union_jack"
import m24 from "./migration/20260602182828_add_project_directories"
import m25 from "./migration/20260603001617_session_message_projection_indexes"
import m26 from "./migration/20260603040000_session_message_projection_order"
import m27 from "./migration/20260603141458_session_input_inbox"
import m28 from "./migration/20260603160727_jittery_ezekiel_stane"
import m29 from "./migration/20260604172448_event_sourced_session_input"
import m30 from "./migration/20260605003541_add_session_context_snapshot"
import m31 from "./migration/20260605042240_add_context_epoch_agent"
import m32 from "./migration/20260611035744_credential"
import m33 from "./migration/20260611192811_lush_chimera"
import m34 from "./migration/20260612174303_project_dir_strategy"
import m35 from "./migration/20260622142730_simplify_session_context_epoch"
import m36 from "./migration/20260622170816_reset_v2_session_state"
import m37 from "./migration/20260622202450_simplify_session_input"
import m38 from "./migration/20260804233008_loose_psylocke"
import m39 from "./migration/20260805200742_import_legacy_credentials"
import m40 from "./migration/20260808023530_workspace_domain"
import m41 from "./migration/20260811161259_execution_claim_attempts"
export const migrations = [
m00,
m01,
m02,
m03,
m04,
m05,
m06,
m07,
m08,
m09,
m10,
m11,
m12,
m13,
m14,
m15,
m16,
m17,
m18,
m19,
m20,
m21,
m22,
m23,
m24,
m25,
m26,
m27,
m28,
m29,
m30,
m31,
m32,
m33,
m34,
m35,
m36,
m37,
m38,
m39,
m40,
m41,
] satisfies DatabaseMigration.Migration[]
export const migrations: DatabaseMigration.Migration[] = (
await Promise.all([
import("./migration/20260127222353_familiar_lady_ursula"),
import("./migration/20260211171708_add_project_commands"),
import("./migration/20260213144116_wakeful_the_professor"),
import("./migration/20260225215848_workspace"),
import("./migration/20260227213759_add_session_workspace_id"),
import("./migration/20260228203230_blue_harpoon"),
import("./migration/20260303231226_add_workspace_fields"),
import("./migration/20260309230000_move_org_to_state"),
import("./migration/20260312043431_session_message_cursor"),
import("./migration/20260323234822_events"),
import("./migration/20260410174513_workspace-name"),
import("./migration/20260413175956_chief_energizer"),
import("./migration/20260423070820_add_icon_url_override"),
import("./migration/20260427172553_slow_nightmare"),
import("./migration/20260428004200_add_session_path"),
import("./migration/20260501142318_next_venus"),
import("./migration/20260504145000_add_sync_owner"),
import("./migration/20260507164347_add_workspace_time"),
import("./migration/20260510033149_session_usage"),
import("./migration/20260511000411_data_migration_state"),
import("./migration/20260511173437_session-metadata"),
import("./migration/20260601010001_normalize_storage_paths"),
import("./migration/20260601202201_amazing_prowler"),
import("./migration/20260602002951_lowly_union_jack"),
import("./migration/20260602182828_add_project_directories"),
import("./migration/20260603001617_session_message_projection_indexes"),
import("./migration/20260603040000_session_message_projection_order"),
import("./migration/20260603141458_session_input_inbox"),
import("./migration/20260603160727_jittery_ezekiel_stane"),
import("./migration/20260604172448_event_sourced_session_input"),
import("./migration/20260605003541_add_session_context_snapshot"),
import("./migration/20260605042240_add_context_epoch_agent"),
import("./migration/20260611035744_credential"),
import("./migration/20260611192811_lush_chimera"),
import("./migration/20260612174303_project_dir_strategy"),
import("./migration/20260622142730_simplify_session_context_epoch"),
import("./migration/20260622170816_reset_v2_session_state"),
import("./migration/20260622202450_simplify_session_input"),
import("./migration/20260804233008_loose_psylocke"),
import("./migration/20260805200742_import_legacy_credentials"),
import("./migration/20260808023530_workspace_domain"),
])
).map((module) => module.default)
+3 -12
View File
@@ -2,7 +2,6 @@ export * as DatabaseMigration from "./migration"
import { sql } from "drizzle-orm"
import { Effect, Semaphore } from "effect"
import { supportsForeignKeyToggle } from "#sqlite"
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { migrations } from "./migration.gen"
import schema from "./schema.gen"
@@ -21,10 +20,8 @@ export type Migration = {
export function apply(db: Database) {
return lock.withPermit(
Effect.gen(function* () {
// OpenCode owns the unprefixed table namespace. Embedders sharing this
// database may own underscore-prefixed tables, which bootstrap ignores.
const tables = yield* db.all<{ name: string }>(
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND substr(name, 1, 1) <> '_'`,
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`,
)
if (tables.some((table) => table.name === "session" || table.name === "session_v2"))
return yield* applyOnly(db, migrations)
@@ -106,15 +103,9 @@ export function applyOnly(db: Database, input: Migration[]) {
})
continue
}
// Durable Object SQLite rejects the foreign_keys toggle; the closest
// allowlisted relaxation is deferring enforcement to transaction commit.
const relaxForeignKeys = supportsForeignKeyToggle
? db.run(sql`PRAGMA foreign_keys = OFF`)
: db.run(sql`PRAGMA defer_foreign_keys = ON`)
const restoreForeignKeys = supportsForeignKeyToggle ? db.run(sql`PRAGMA foreign_keys = ON`) : Effect.void
yield* relaxForeignKeys
yield* db.run(sql`PRAGMA foreign_keys = OFF`)
yield* apply.pipe(
Effect.ensuring(restoreForeignKeys.pipe(Effect.orDie)),
Effect.ensuring(db.run(sql`PRAGMA foreign_keys = ON`).pipe(Effect.orDie)),
Effect.tapError((error) =>
Effect.logError("database migration failed", {
migration: migration.id,
@@ -1,13 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
id: "20260811161259_execution_claim_attempts",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`resume_attempts\` integer DEFAULT 0 NOT NULL;`)
})
},
}
export default migration
-1
View File
@@ -200,7 +200,6 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
\`time_compacting\` integer,
\`time_archived\` integer,
\`time_suspended\` integer,
\`resume_attempts\` integer DEFAULT 0 NOT NULL,
CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
+84 -18
View File
@@ -1,37 +1,58 @@
import { Database, type SQLQueryBindings } from "bun:sqlite"
import { Database } from "bun:sqlite"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { Context, Effect, Layer } from "effect"
import { Context, Effect, Fiber, Layer, Scope, Semaphore, Stream } from "effect"
import { identity } from "effect/Function"
import { Reactivity } from "effect/unstable/reactivity"
import { SqlClient } from "effect/unstable/sql"
import { SqlClient, Statement } from "effect/unstable/sql"
import type { Connection } from "effect/unstable/sql/SqlConnection"
import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
import { Sqlite } from "./sqlite"
const ATTR_DB_SYSTEM_NAME = "db.system.name"
const TypeId = "~@opencode-ai/core/database/SqliteBun" as const
type TypeId = typeof TypeId
export const supportsTuningPragmas = true
interface SqliteClient extends SqlClient.SqlClient {
readonly [TypeId]: TypeId
readonly config: Config
readonly export: Effect.Effect<Uint8Array, SqlError>
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
readonly updateValues: never
}
// Foreign keys default OFF and can be toggled per connection.
export const supportsForeignKeyToggle = true
interface Config extends Sqlite.ClientConfig {
interface Config {
readonly filename: string
readonly readonly?: boolean
readonly create?: boolean
readonly readwrite?: boolean
readonly disableWAL?: boolean
readonly spanAttributes?: Record<string, unknown>
readonly transformResultNames?: (str: string) => string
readonly transformQueryNames?: (str: string) => string
}
interface SqliteConnection extends Connection {
readonly export: Effect.Effect<Uint8Array, SqlError>
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
}
const make = (options: Config) =>
Effect.gen(function* () {
const native = (yield* Sqlite.Native) as Database
const compiler = Statement.makeCompilerSqlite(options.transformQueryNames)
const transformRows = options.transformResultNames
? Statement.defaultTransforms(options.transformResultNames).array
: undefined
const run = (query: string, params: ReadonlyArray<unknown> = []) =>
Effect.withFiber<Array<Record<string, unknown>>, SqlError>((fiber) => {
const statement = native.query<Record<string, unknown>, SQLQueryBindings[]>(query)
const statement = native.query(query)
// @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627
statement.safeIntegers(Context.get(fiber.context, SqlClient.SafeIntegers))
try {
return Effect.succeed(statement.all(...(params as SQLQueryBindings[])) ?? [])
return Effect.succeed((statement.all(...(params as any)) ?? []) as Array<Record<string, unknown>>)
} catch (cause) {
return Effect.fail(
new SqlError({
@@ -43,11 +64,11 @@ const make = (options: Config) =>
const runValues = (query: string, params: ReadonlyArray<unknown> = []) =>
Effect.withFiber<Array<unknown[]>, SqlError>((fiber) => {
const statement = native.query<unknown, SQLQueryBindings[]>(query)
const statement = native.query(query)
// @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627
statement.safeIntegers(Context.get(fiber.context, SqlClient.SafeIntegers))
try {
return Effect.succeed(statement.values(...(params as SQLQueryBindings[])) ?? [])
return Effect.succeed((statement.values(...(params as any)) ?? []) as Array<unknown[]>)
} catch (cause) {
return Effect.fail(
new SqlError({
@@ -57,7 +78,25 @@ const make = (options: Config) =>
}
})
const connection = Sqlite.makeConnection(run, runValues, {
const connection = identity<SqliteConnection>({
execute(query, params, transformRows) {
return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params)
},
executeRaw(query, params) {
return run(query, params)
},
executeValues(query, params) {
return runValues(query, params)
},
executeValuesUnprepared(query, params) {
return runValues(query, params)
},
executeUnprepared(query, params, transformRows) {
return this.execute(query, params, transformRows)
},
executeStream() {
return Stream.die("executeStream not implemented")
},
export: Effect.try({
try: () => native.serialize(),
catch: (cause) =>
@@ -65,7 +104,7 @@ const make = (options: Config) =>
reason: classifySqliteError(cause, { message: "Failed to export database", operation: "export" }),
}),
}),
loadExtension: (path: string) =>
loadExtension: (path) =>
Effect.try({
try: () => native.loadExtension(path),
catch: (cause) =>
@@ -75,10 +114,37 @@ const make = (options: Config) =>
}),
})
return yield* Sqlite.makeClient(options, connection, TypeId, (acquirer) => ({
export: Effect.flatMap(acquirer, (_) => _.export),
loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path)),
}))
const semaphore = yield* Semaphore.make(1)
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
const fiber = Fiber.getCurrent()!
const scope = Context.getUnsafe(fiber.context, Scope.Scope)
return Effect.as(
Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))),
connection,
)
})
const client = Object.assign(
(yield* SqlClient.make({
acquirer,
compiler,
transactionAcquirer,
spanAttributes: [
...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
[ATTR_DB_SYSTEM_NAME, "sqlite"],
],
transformRows,
})) as SqliteClient,
{
[TypeId]: TypeId,
config: options,
export: Effect.flatMap(acquirer, (_) => _.export),
loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path)),
},
)
return client
})
const nativeLayer = (config: Config) =>
+76 -12
View File
@@ -1,19 +1,26 @@
import { DatabaseSync, type SQLInputValue } from "node:sqlite"
import { drizzle } from "drizzle-orm/node-sqlite"
import { Context, Effect, Layer } from "effect"
import { Context, Effect, Fiber, Layer, Scope, Semaphore, Stream } from "effect"
import { identity } from "effect/Function"
import { Reactivity } from "effect/unstable/reactivity"
import { SqlClient } from "effect/unstable/sql"
import { SqlClient, Statement } from "effect/unstable/sql"
import type { Connection } from "effect/unstable/sql/SqlConnection"
import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
import { Sqlite } from "./sqlite"
const ATTR_DB_SYSTEM_NAME = "db.system.name"
const TypeId = "~@opencode-ai/core/database/SqliteNode" as const
type TypeId = typeof TypeId
export const supportsTuningPragmas = true
interface SqliteClient extends SqlClient.SqlClient {
readonly [TypeId]: TypeId
readonly config: Config
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
readonly updateValues: never
}
// Foreign keys default OFF and can be toggled per connection.
export const supportsForeignKeyToggle = true
interface Config extends Sqlite.ClientConfig {
interface Config {
readonly filename: string
readonly readonly?: boolean
readonly create?: boolean
@@ -21,12 +28,24 @@ interface Config extends Sqlite.ClientConfig {
readonly disableWAL?: boolean
readonly timeout?: number
readonly allowExtension?: boolean
readonly spanAttributes?: Record<string, unknown>
readonly transformResultNames?: (str: string) => string
readonly transformQueryNames?: (str: string) => string
}
interface SqliteConnection extends Connection {
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
}
const make = (options: Config) =>
Effect.gen(function* () {
const native = (yield* Sqlite.Native) as DatabaseSync
const compiler = Statement.makeCompilerSqlite(options.transformQueryNames)
const transformRows = options.transformResultNames
? Statement.defaultTransforms(options.transformResultNames).array
: undefined
const run = (query: string, params: ReadonlyArray<unknown> = []) =>
Effect.withFiber<Array<Record<string, unknown>>, SqlError>((fiber) => {
const statement = native.prepare(query)
@@ -60,8 +79,26 @@ const make = (options: Config) =>
}
})
const connection = Sqlite.makeConnection(run, runValues, {
loadExtension: (path: string) =>
const connection = identity<SqliteConnection>({
execute(query, params, transformRows) {
return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params)
},
executeRaw(query, params) {
return run(query, params)
},
executeValues(query, params) {
return runValues(query, params)
},
executeValuesUnprepared(query, params) {
return runValues(query, params)
},
executeUnprepared(query, params, transformRows) {
return this.execute(query, params, transformRows)
},
executeStream() {
return Stream.die("executeStream not implemented")
},
loadExtension: (path) =>
Effect.try({
try: () => native.loadExtension(path),
catch: (cause) =>
@@ -71,9 +108,36 @@ const make = (options: Config) =>
}),
})
return yield* Sqlite.makeClient(options, connection, TypeId, (acquirer) => ({
loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path)),
}))
const semaphore = yield* Semaphore.make(1)
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
const fiber = Fiber.getCurrent()!
const scope = Context.getUnsafe(fiber.context, Scope.Scope)
return Effect.as(
Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))),
connection,
)
})
const client = Object.assign(
(yield* SqlClient.make({
acquirer,
compiler,
transactionAcquirer,
spanAttributes: [
...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
[ATTR_DB_SYSTEM_NAME, "sqlite"],
],
transformRows,
})) as SqliteClient,
{
[TypeId]: TypeId,
config: options,
loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path)),
},
)
return client
})
const nativeLayer = (config: Config) =>
+1 -93
View File
@@ -1,100 +1,8 @@
export * as Sqlite from "./sqlite"
import { Context, Effect, Fiber, Scope, Semaphore, Stream } from "effect"
import { identity } from "effect/Function"
import { SqlClient, Statement } from "effect/unstable/sql"
import type { Connection } from "effect/unstable/sql/SqlConnection"
import type { SqlError } from "effect/unstable/sql/SqlError"
import { Context } from "effect"
import type { drizzle } from "drizzle-orm/bun-sqlite"
export type DrizzleClient = ReturnType<typeof drizzle>
export class Native extends Context.Service<Native, unknown>()("@opencode-ai/core/database/SqliteNative") {}
export class Drizzle extends Context.Service<Drizzle, DrizzleClient>()("@opencode-ai/core/database/SqliteDrizzle") {}
export interface ClientConfig {
readonly spanAttributes?: Record<string, unknown>
readonly transformResultNames?: (str: string) => string
readonly transformQueryNames?: (str: string) => string
}
type Run = (
query: string,
params?: ReadonlyArray<unknown>,
) => Effect.Effect<ReadonlyArray<Record<string, unknown>>, SqlError>
type RunValues = (
query: string,
params?: ReadonlyArray<unknown>,
) => Effect.Effect<ReadonlyArray<ReadonlyArray<unknown>>, SqlError>
export const makeConnection = <Extensions extends object>(run: Run, runValues: RunValues, extensions: Extensions) =>
identity<Connection & Extensions>({
execute(query, params, transformRows) {
return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params)
},
executeRaw(query, params) {
return run(query, params)
},
executeValues(query, params) {
return runValues(query, params)
},
executeValuesUnprepared(query, params) {
return runValues(query, params)
},
executeUnprepared(query, params, transformRows) {
return this.execute(query, params, transformRows)
},
executeStream() {
return Stream.die("executeStream not implemented")
},
...extensions,
})
export const makeClient = <
Config extends ClientConfig,
SqliteConnection extends Connection,
const TypeId extends string,
Extensions extends object,
>(
options: Config,
connection: SqliteConnection,
typeId: TypeId,
extensions: (acquirer: Effect.Effect<SqliteConnection, SqlError, Scope.Scope>) => Extensions,
) =>
Effect.gen(function* () {
const semaphore = yield* Semaphore.make(1)
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
const fiber = Fiber.getCurrent()!
const scope = Context.getUnsafe(fiber.context, Scope.Scope)
return Effect.as(
Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))),
connection,
)
})
const transformRows = options.transformResultNames
? Statement.defaultTransforms(options.transformResultNames).array
: undefined
return Object.assign(
yield* SqlClient.make({
acquirer,
compiler: Statement.makeCompilerSqlite(options.transformQueryNames),
transactionAcquirer,
spanAttributes: [
...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
["db.system.name", "sqlite"],
],
transformRows,
}),
{
[typeId]: typeId,
config: options,
...extensions(acquirer),
},
) as SqlClient.SqlClient &
Record<TypeId, TypeId> & {
readonly config: Config
readonly updateValues: never
} & Extensions
})
@@ -1,254 +0,0 @@
import { drizzle } from "drizzle-orm/durable-sqlite"
import { Context, Effect, Exit, Fiber, Layer, Scope, Semaphore, Stream } from "effect"
import { identity } from "effect/Function"
import { Reactivity } from "effect/unstable/reactivity"
import { SqlClient, Statement } from "effect/unstable/sql"
import type { Connection } from "effect/unstable/sql/SqlConnection"
import { classifySqliteError, SqlError, UnknownError } from "effect/unstable/sql/SqlError"
import { Sqlite } from "./sqlite"
const ATTR_DB_SYSTEM_NAME = "db.system.name"
const TypeId = "~@opencode-ai/core/database/SqliteWorkerd" as const
type TypeId = typeof TypeId
// Durable Object SQLite only allowlists introspection pragmas; journal_mode,
// synchronous, busy_timeout, cache_size, and wal_checkpoint all throw, and
// foreign keys are already enforced by default (SQLITE_DEFAULT_FOREIGN_KEYS=1).
export const supportsTuningPragmas = false
// Durable Object SQLite rejects `PRAGMA foreign_keys`: enforcement is always
// on (SQLITE_DEFAULT_FOREIGN_KEYS=1) and only `defer_foreign_keys` is
// allowlisted for migrations that must relax checking inside a transaction.
export const supportsForeignKeyToggle = false
// Minimal structural types for the Durable Object storage API so this adapter
// does not depend on @cloudflare/workers-types (whose ambient globals conflict
// with @types/bun). Shapes match the SqlStorage and DurableObjectStorage docs.
type SqlStorageValue = ArrayBuffer | string | number | null
interface SqlStorageCursor {
readonly columnNames: Array<string>
raw(): IterableIterator<Array<SqlStorageValue>>
toArray(): Array<Record<string, SqlStorageValue>>
}
export interface SqlStorage {
exec(query: string, ...bindings: Array<unknown>): SqlStorageCursor
}
export interface DurableObjectStorage {
readonly sql: SqlStorage
transaction<T>(closure: (txn: { rollback(): void }) => Promise<T>): Promise<T>
transactionSync<T>(closure: () => T): T
}
interface SqliteClient extends SqlClient.SqlClient {
readonly [TypeId]: TypeId
readonly config: Config
readonly updateValues: never
}
interface Config {
readonly storage: DurableObjectStorage
readonly spanAttributes?: Record<string, unknown>
readonly transformResultNames?: (str: string) => string
readonly transformQueryNames?: (str: string) => string
}
// sql.exec() rejects BEGIN/COMMIT/SAVEPOINT, so SqlClient.make's default
// transaction SQL can never run. withTransaction is replaced below with a
// DurableObjectStorage.transaction-backed implementation; this service only
// tracks the active transaction connection for statements and nesting checks.
const WorkerdTransaction = Context.Service<SqlClient.TransactionConnection, SqlClient.TransactionConnection.Service>(
"@opencode-ai/core/database/SqliteWorkerdTransaction",
)
const transactionError = (message: string) =>
new SqlError({
reason: new UnknownError({ cause: new Error(message), message, operation: "transaction" }),
})
const makeWithTransaction =
(
storage: DurableObjectStorage,
connection: Connection,
semaphore: Semaphore.Semaphore,
): SqlClient.SqlClient["withTransaction"] =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E | SqlError, R> =>
Effect.withFiber((fiber) => {
const services = fiber.context
if (Context.getOption(services, WorkerdTransaction)._tag === "Some")
return Effect.fail(
transactionError("Nested transactions are not supported by Cloudflare Durable Object SQLite storage"),
)
const effectWithTxn = Effect.provideContext(
effect,
Context.add(services, WorkerdTransaction, [connection, 0] as const),
)
return semaphore.withPermits(1)(
Effect.callback((resume) => {
let interrupted = false
const promise = storage
.transaction(
(txn) =>
new Promise<void>((resolve) => {
if (interrupted) return resolve()
resume(
Effect.onExit(effectWithTxn, (exit) => {
if (Exit.isFailure(exit)) txn.rollback()
resolve()
// wait for the transaction to complete
return Effect.promise(() => promise)
}),
)
}),
)
.catch((cause) =>
resume(
Effect.fail(
new SqlError({
reason: classifySqliteError(cause, { message: "Failed transaction", operation: "transaction" }),
}),
),
),
)
return Effect.suspend(() => {
interrupted = true
return Effect.promise(() => promise)
})
}),
)
})
const make = (options: Config) =>
Effect.gen(function* () {
const native = (yield* Sqlite.Native) as DurableObjectStorage
const compiler = Statement.makeCompilerSqlite(options.transformQueryNames)
const transformRows = options.transformResultNames
? Statement.defaultTransforms(options.transformResultNames).array
: undefined
// SqlClient.SafeIntegers is ignored: Durable Object SQLite has no bigint
// mode and always returns integers as numbers. Blobs come back as
// ArrayBuffer and are normalized to Uint8Array to match the other adapters.
function* runIterator(query: string, params: ReadonlyArray<unknown> = []) {
const cursor = native.sql.exec(query, ...params)
const columns = cursor.columnNames
for (const row of cursor.raw()) {
const record: Record<string, unknown> = {}
for (let i = 0; i < columns.length; i++) {
const value = row[i]
record[columns[i]] = value instanceof ArrayBuffer ? new Uint8Array(value) : value
}
yield record
}
}
const run = (query: string, params: ReadonlyArray<unknown> = []) =>
Effect.try({
try: () => Array.from(runIterator(query, params)),
catch: (cause) =>
new SqlError({
reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }),
}),
})
const runValues = (query: string, params: ReadonlyArray<unknown> = []) =>
Effect.try({
try: () =>
Array.from(native.sql.exec(query, ...params).raw(), (row) =>
row.map((value) => (value instanceof ArrayBuffer ? new Uint8Array(value) : value)),
),
catch: (cause) =>
new SqlError({
reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }),
}),
})
const connection = identity<Connection>({
execute(query, params, transformRows) {
return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params)
},
executeRaw(query, params) {
return run(query, params)
},
executeValues(query, params) {
return runValues(query, params)
},
executeValuesUnprepared(query, params) {
return runValues(query, params)
},
executeUnprepared(query, params, transformRows) {
return this.execute(query, params, transformRows)
},
executeStream() {
return Stream.die("executeStream not implemented")
},
})
const semaphore = yield* Semaphore.make(1)
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
const fiber = Fiber.getCurrent()!
const scope = Context.getUnsafe(fiber.context, Scope.Scope)
return Effect.as(
Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))),
connection,
)
})
const client = Object.assign(
(yield* SqlClient.make({
acquirer,
compiler,
transactionAcquirer,
transactionService: WorkerdTransaction,
spanAttributes: [
...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
[ATTR_DB_SYSTEM_NAME, "sqlite"],
],
transformRows,
})) as SqliteClient,
{
[TypeId]: TypeId,
config: options,
withTransaction: makeWithTransaction(native, connection, semaphore),
// Durable Object SQLite rejects BEGIN/COMMIT/SAVEPOINT; consumers such
// as the drizzle session must route through withTransaction instead.
transactionStatements: false,
},
)
return client
})
// Defends against the shared path-based Database.layer, which passes a
// filename instead of storage when resolved under the workerd condition.
const nativeLayer = (config: Config) =>
config.storage
? Layer.succeed(Sqlite.Native, config.storage)
: Layer.effect(
Sqlite.Native,
Effect.die(
"workerd sqlite cannot open a database from a path; use Database.layerWith(sqliteLayer({ storage }))",
),
)
const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
const drizzleLayer = Layer.effect(
Sqlite.Drizzle,
Effect.gen(function* () {
const native = (yield* Sqlite.Native) as DurableObjectStorage
return drizzle(native) as unknown as Sqlite.DrizzleClient
}),
)
export const sqliteLayer = (config: Config) => {
const native = nativeLayer(config)
return Layer.merge(native, Layer.merge(clientLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
Layer.provide(Reactivity.layer),
)
}
+2 -11
View File
@@ -7,7 +7,7 @@ import { SessionV1 } from "@opencode-ai/schema/session-v1"
import { SessionMessage } from "../session/message"
import { SessionSchema } from "../session/schema"
import { KVTable } from "../kv/sql"
import { EventSequenceTable } from "../event/sql"
import { EventSequenceTable, EventTable } from "../event/sql"
import { eq, sql } from "drizzle-orm"
import { Global } from "@opencode-ai/util/global"
import { existsSync } from "node:fs"
@@ -161,7 +161,6 @@ type NextMessage = {
const lock = Semaphore.makeUnsafe(1)
const MIGRATION_STATE_KEY = "migration.v1-v2"
const EVENT_DELETE_BATCH_SIZE = 1_000
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const decodeMessage = Schema.decodeUnknownOption(SessionV1.Info)
const decodePart = Schema.decodeUnknownOption(SessionV1.Part)
@@ -486,15 +485,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
yield* db
.transaction((tx) =>
Effect.gen(function* () {
while (true) {
yield* tx.run(sql`
DELETE FROM event
WHERE rowid IN (SELECT rowid FROM event LIMIT ${EVENT_DELETE_BATCH_SIZE})
`)
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
if (deleted < EVENT_DELETE_BATCH_SIZE) break
yield* Effect.yieldNow
}
yield* tx.delete(EventTable).run()
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
+94 -7
View File
@@ -1,15 +1,102 @@
import { FileFinder } from "@ff-labs/fff-bun"
import { bind } from "./fff"
export type { Directory, DirSearch, File, Init, Mixed, MixedSearch, Picker, Result, Search } from "./fff"
import {
FileFinder,
type DirItem,
type DirSearchResult,
type FileItem,
type InitOptions,
type MixedItem,
type MixedSearchResult,
type SearchResult,
} from "@ff-labs/fff-bun"
declare global {
const FFF_LIBC: "gnu" | "musl"
}
const adapter = bind(FileFinder)
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
export const available = adapter.available
export const create = adapter.create
export type Init = InitOptions
export interface Search {
items: FileItem[]
scores: SearchResult["scores"]
totalMatched: number
totalFiles: number
}
export interface DirSearch {
items: DirItem[]
scores: DirSearchResult["scores"]
totalMatched: number
totalDirs: number
}
export interface MixedSearch {
items: MixedItem[]
scores: MixedSearchResult["scores"]
totalMatched: number
totalFiles: number
totalDirs: number
}
export type File = FileItem
export type Directory = DirItem
export type Mixed = MixedItem
export interface Picker {
destroy(): void
isScanning(): boolean
waitForScan(timeoutMs?: number): Promise<Result<boolean>>
refreshGitStatus(): Result<number>
fileSearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<Search>
directorySearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<DirSearch>
mixedSearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<MixedSearch>
trackQuery(query: string, file: string): Result<boolean>
getHistoricalQuery(offset: number): Result<string | null>
}
export function available() {
return FileFinder.isAvailable()
}
export function create(opts: Init): Result<Picker> {
const made = FileFinder.create(opts)
if (!made.ok) return made
const pick = made.value
return {
ok: true,
value: {
destroy: () => pick.destroy(),
isScanning: () => pick.isScanning(),
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
refreshGitStatus: () => pick.refreshGitStatus(),
fileSearch: (query, next) => pick.fileSearch(query, next),
directorySearch: (query, next) => pick.directorySearch(query, next),
mixedSearch: (query, next) => pick.mixedSearch(query, next),
trackQuery: (query, file) => pick.trackQuery(query, file),
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
},
}
}
export * as Fff from "./fff.bun"
+94 -6
View File
@@ -1,12 +1,100 @@
import { bind } from "./fff"
export type { Directory, DirSearch, File, Init, Mixed, MixedSearch, Picker, Result, Search } from "./fff"
import type {
DirItem,
DirSearchResult,
FileItem,
InitOptions,
MixedItem,
MixedSearchResult,
SearchResult,
} from "@ff-labs/fff-node"
const { FileFinder } = await import("@ff-labs/fff-node").catch(() => ({ FileFinder: undefined }))
const adapter = bind(FileFinder, "fff unavailable on node runtime")
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
export const available = adapter.available
export const create = adapter.create
export type Init = InitOptions
export interface Search {
items: FileItem[]
scores: SearchResult["scores"]
totalMatched: number
totalFiles: number
}
export interface DirSearch {
items: DirItem[]
scores: DirSearchResult["scores"]
totalMatched: number
totalDirs: number
}
export interface MixedSearch {
items: MixedItem[]
scores: MixedSearchResult["scores"]
totalMatched: number
totalFiles: number
totalDirs: number
}
export type File = FileItem
export type Directory = DirItem
export type Mixed = MixedItem
export interface Picker {
destroy(): void
isScanning(): boolean
waitForScan(timeoutMs?: number): Promise<Result<boolean>>
refreshGitStatus(): Result<number>
fileSearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<Search>
directorySearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<DirSearch>
mixedSearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<MixedSearch>
trackQuery(query: string, file: string): Result<boolean>
getHistoricalQuery(offset: number): Result<string | null>
}
export function available() {
return FileFinder?.isAvailable() ?? false
}
export function create(opts: Init): Result<Picker> {
if (!FileFinder) return { ok: false, error: "fff unavailable on node runtime" }
const made = FileFinder.create(opts)
if (!made.ok) return made
const pick = made.value
return {
ok: true,
value: {
destroy: () => pick.destroy(),
isScanning: () => pick.isScanning(),
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
refreshGitStatus: () => pick.refreshGitStatus(),
fileSearch: (query, next) => pick.fileSearch(query, next),
directorySearch: (query, next) => pick.directorySearch(query, next),
mixedSearch: (query, next) => pick.mixedSearch(query, next),
trackQuery: (query, file) => pick.trackQuery(query, file),
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
},
}
}
export * as Fff from "./fff.node"
-66
View File
@@ -1,66 +0,0 @@
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
export interface Init {
basePath: string
aiMode?: boolean
disableMmapCache?: boolean
disableContentIndexing?: boolean
}
export interface SearchOptions {
currentFile?: string
pageIndex?: number
pageSize?: number
}
export interface File {
relativePath: string
}
export interface Directory {
relativePath: string
}
export type Mixed = { type: "file"; item: File } | { type: "directory"; item: Directory }
export interface Score {
total: number
}
export interface Search {
items: File[]
scores: Score[]
}
export interface DirSearch {
items: Directory[]
scores: Score[]
}
export interface MixedSearch {
items: Mixed[]
scores: Score[]
}
export interface Picker {
destroy(): void
fileSearch(query: string, options?: SearchOptions): Result<Search>
directorySearch(query: string, options?: SearchOptions): Result<DirSearch>
mixedSearch(query: string, options?: SearchOptions): Result<MixedSearch>
}
export interface Backend {
isAvailable(): boolean
create(options: Init): Result<Picker>
}
export function bind(backend: Backend | undefined, unavailable = "fff unavailable") {
return {
available: () => backend?.isAvailable() ?? false,
create: (options: Init): Result<Picker> =>
backend?.create(options) ?? {
ok: false,
error: unavailable,
},
}
}
+1 -7
View File
@@ -126,12 +126,6 @@ function build(id: Model.ID, remote: UsableModel, baseURL: string, previous?: Mo
const image =
(remote.capabilities.supports.vision ?? false) ||
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
const pdf =
(remote.capabilities.supports.vision ?? false) &&
(remote.capabilities.limits.vision?.supported_media_types.includes("application/pdf") ?? false)
const input = ["text"]
if (image) input.push("image")
if (pdf) input.push("pdf")
const prices = remote.billing?.token_prices
// Copilot reports AIC per billing batch; OpenCode stores USD per million tokens.
const usdPerMillion = prices && prices.batch_size > 0 ? 10_000 / prices.batch_size : 0
@@ -156,7 +150,7 @@ function build(id: Model.ID, remote: UsableModel, baseURL: string, previous?: Mo
body: previous?.body,
capabilities: {
tools: remote.capabilities.supports.tool_calls,
input,
input: image ? ["text", "image"] : ["text"],
output: ["text"],
},
variants: variants(remote, messages),
+70 -110
View File
@@ -158,42 +158,45 @@ export const fromCatalogModel = (
model: Info,
credential?: Credential.Value,
dependencies?: Dependencies,
): Effect.Effect<LanguageModel, UnsupportedPackageError | UnresolvedProviderVariablesError> =>
resolveCatalogModel(model, credential, dependencies).pipe(
Effect.flatMap((resolved) => validateProviderVariables(model, resolved)),
)
const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(function* (
model: Info,
credential?: Credential.Value,
dependencies?: Dependencies,
) {
const resolved = prepareRuntimeModel(model, credential)
): Effect.Effect<LanguageModel, UnsupportedPackageError | UnresolvedProviderVariablesError> => {
const prepared = prepareRuntimeModel(model, credential)
if (prepared.unresolved.length > 0)
return Effect.fail(
new UnresolvedProviderVariablesError({
providerID: model.providerID,
modelID: model.id,
variables: prepared.unresolved,
}),
)
const resolved = prepared.model
const packageName = Provider.packageName(resolved.package)
const key = apiKey(resolved, credential)
const configuration = credential?.type === "key" ? credential.configuration : undefined
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
const runtime = yield* prepareProviderModel(resolved)
return withDefaults(runtime, OpenAIResponses.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
return Effect.succeed(
withDefaults(resolved, OpenAIResponses.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") {
const runtime = yield* prepareProviderModel(resolved)
return withDefaults(runtime, AnthropicMessages.route)
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
return Effect.succeed(
withDefaults(resolved, AnthropicMessages.route)
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (
Provider.isAISDK(resolved.package) &&
packageName === "@ai-sdk/openai-compatible" &&
typeof resolved.settings?.baseURL === "string"
) {
const runtime = yield* prepareProviderModel(resolved)
return withDefaults(runtime, OpenAICompatibleChat.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
return Effect.succeed(
withDefaults(resolved, OpenAICompatibleChat.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
const configured = { ...resolved.settings, ...credential?.metadata, ...configuration }
const mapping = Provider.isAISDK(resolved.package)
@@ -205,107 +208,64 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
: undefined
const native = mapping?.package ?? resolved.package
if (Provider.isAISDK(resolved.package) && !mapping) {
const loadAISDK = dependencies?.loadAISDK
if (!loadAISDK) return yield* unsupported(resolved)
const settings = yield* prepareProviderSettings(
resolved,
Provider.mergeOverlay(resolved.settings, {
if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved))
const runtime = produce(resolved, (draft) => {
draft.settings = Provider.mergeOverlay(draft.settings, {
...nativeCredentialSettings(resolved.package ?? "", credential),
...credential?.metadata,
...configuration,
}) ?? {},
)
const runtime = produce(resolved, (draft) => {
draft.settings = settings
})
})
return yield* loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
}
if (!native) return yield* unsupported(resolved)
if (!native) return Effect.fail(unsupported(resolved))
const specifier = native
const mapped = yield* prepareProviderSettings(resolved, mapping?.settings ?? configured)
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
Effect.mapError(() => unsupported(resolved)),
)
const settings = {
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
...nativeCredentialSettings(specifier, credential),
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
body: Provider.mergeOverlay(mapping?.body, resolved.body),
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
}
return yield* Effect.try({
try: () => {
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
return LanguageModel.update(runtime, {
provider: resolved.providerID,
compatibility: resolved.compatibility
? Object.assign({}, runtime.compatibility, resolved.compatibility)
: runtime.compatibility,
})
},
catch: () => unsupported(resolved),
return Effect.gen(function* () {
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
Effect.mapError(() => unsupported(resolved)),
)
const mapped = mapping?.settings ?? configured
const settings = {
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
...nativeCredentialSettings(specifier, credential),
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
body: Provider.mergeOverlay(mapping?.body, resolved.body),
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
}
return yield* Effect.try({
try: () => {
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
return LanguageModel.update(runtime, {
provider: resolved.providerID,
compatibility: resolved.compatibility
? Object.assign({}, runtime.compatibility, resolved.compatibility)
: runtime.compatibility,
})
},
catch: () => unsupported(resolved),
})
})
})
}
function prepareRuntimeModel(model: Info, credential: Credential.Value | undefined) {
if (model.settings?.apiKey !== "" && (credential?.type !== "key" || credential.metadata === undefined)) return model
return produce(model, (draft) => {
const prepared = produce(model, (draft) => {
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
if (credential?.type === "key" && credential.metadata !== undefined)
draft.body = Provider.mergeOverlay(draft.body, credential.metadata)
if (typeof draft.settings?.baseURL !== "string") return
draft.settings.baseURL = draft.settings.baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => {
return process.env[name] ?? placeholder
})
})
}
function validateProviderVariables(
model: Info,
resolved: LanguageModel,
): Effect.Effect<LanguageModel, UnresolvedProviderVariablesError> {
const baseURL = resolved.route.endpoint.baseURL
if (typeof baseURL !== "string") return Effect.succeed(resolved)
const failure = unresolvedProviderVariables(model, baseURL)
return failure ? Effect.fail(failure) : Effect.succeed(resolved)
}
function prepareProviderModel(model: Info): Effect.Effect<Info, UnresolvedProviderVariablesError> {
if (!model.settings) return Effect.succeed(model)
return prepareProviderSettings(model, model.settings).pipe(
Effect.map((settings) =>
settings === model.settings
? model
: produce(model, (draft) => {
draft.settings = settings
}),
),
)
}
function prepareProviderSettings(
model: Info,
settings: Readonly<Record<string, unknown>>,
): Effect.Effect<Readonly<Record<string, unknown>>, UnresolvedProviderVariablesError> {
const baseURL = settings.baseURL
if (typeof baseURL !== "string") return Effect.succeed(settings)
return prepareProviderURL(model, baseURL).pipe(
Effect.map((prepared) => (prepared === baseURL ? settings : { ...settings, baseURL: prepared })),
)
}
function prepareProviderURL(model: Info, baseURL: string): Effect.Effect<string, UnresolvedProviderVariablesError> {
if (!baseURL.includes("${")) return Effect.succeed(baseURL)
const prepared = baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => process.env[name] ?? placeholder)
const failure = unresolvedProviderVariables(model, prepared)
return failure ? Effect.fail(failure) : Effect.succeed(prepared)
}
function unresolvedProviderVariables(model: Info, baseURL: string) {
const variables = new Set(Array.from(baseURL.matchAll(/\$\{([^}]+)\}/g), (match) => match[1]))
if (variables.size === 0) return
return new UnresolvedProviderVariablesError({
providerID: model.providerID,
modelID: model.id,
variables: Array.from(variables),
})
const baseURL = prepared.settings?.baseURL
const unresolved =
typeof baseURL === "string"
? Array.from(baseURL.matchAll(/\$\{([^}]+)\}/g), (match) => match[1]).filter(
(name, index, names) => names.indexOf(name) === index,
)
: []
return { model: prepared, unresolved }
}
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
+2 -7
View File
@@ -4,7 +4,7 @@ export { Event, ID, Info } from "@opencode-ai/schema/plugin"
import { Plugin } from "@opencode-ai/schema/plugin"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "./app"
import { Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect"
import { Agent } from "./agent"
import { AISDK } from "./aisdk"
import { Catalog } from "./catalog"
@@ -44,12 +44,7 @@ const layer = Layer.effect(
const inherit = yield* State.inherit()
const loaded = yield* Effect.suspend(() => plugin.effect(host)).pipe(
inherit,
Effect.updateContext((context: Context.Context<never>) =>
Context.make(Scope.Scope, child).pipe(
Context.add(Logger.CurrentLoggers, Context.get(context, Logger.CurrentLoggers)),
Context.add(References.MinimumLogLevel, Context.get(context, References.MinimumLogLevel)),
),
),
Effect.updateContext((_context: Context.Context<never>) => Context.make(Scope.Scope, child)),
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": plugin.id } }),
Effect.andThen(bus.publish(Plugin.Event.Added, { id: Plugin.ID.make(plugin.id) })),
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
+10
View File
@@ -101,6 +101,16 @@ export const Plugin = define({
item.permissions.push({ action: "question", resource: "*", effect: "allow" })
})
draft.update(Agent.ID.make("plan"), (item) => {
item.name = Agent.Name.make("Plan")
item.description = "Plan mode. Disallows all edit tools."
item.mode = "primary"
item.permissions.push(
{ action: "question", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
)
})
draft.update(Agent.ID.make("general"), (item) => {
item.name = Agent.Name.make("General")
item.description =
-6
View File
@@ -17,7 +17,6 @@ import { ConfigProviderPlugin } from "../config/plugin/provider"
import { ConfigPolicyPlugin } from "../config/plugin/policy"
import { ConfigReferencePlugin } from "../config/plugin/reference"
import { ConfigSkillPlugin } from "../config/plugin/skill"
import { ConfigPluginSource } from "../config/plugin/source"
import { ConfigWebSearchPlugin } from "../config/plugin/websearch"
import { Bus } from "../bus"
import { Environment } from "../environment"
@@ -61,7 +60,6 @@ import { WellKnown } from "../wellknown"
import { WriteTool } from "../tool/plugin/write"
import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
import { PlanPlugin } from "./plan"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
import { WebSearchPlugins } from "./websearch"
@@ -78,7 +76,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const command = yield* Command.Service
const config = yield* Config.Service
const credential = yield* Credential.Service
const pluginSources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const environment = yield* Environment.Service
const mutation = yield* FileMutation.Service
@@ -115,7 +112,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Command.Service, command),
Context.make(Config.Service, config),
Context.make(Credential.Service, credential),
Context.make(ConfigPluginSource.Service, pluginSources),
Context.make(Bus.Service, bus),
Context.make(Environment.Service, environment),
Context.make(FileMutation.Service, mutation),
@@ -159,7 +155,6 @@ export const requirements = LayerNode.group([
Command.node,
Config.node,
Credential.node,
ConfigPluginSource.node,
Bus.node,
Environment.node,
FileMutation.node,
@@ -197,7 +192,6 @@ export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
WellKnownPlugin.Plugin,
AgentPlugin.Plugin,
PlanPlugin.Plugin,
CommandPlugin.Plugin,
SkillPlugin.Plugin,
...SystemPromptPlugin.Plugins,
-1
View File
@@ -36,7 +36,6 @@ export const ModelsDevPlugin = define({
draft.integrationID = Integration.ID.make(provider.info.id)
})
for (const model of provider.models) {
if (model.status === "deprecated") continue
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, model))
}
}
-74
View File
@@ -1,74 +0,0 @@
export * as PlanPlugin from "./plan"
import { ToolFailure } from "@opencode-ai/ai"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Agent } from "../agent"
import { SessionEvent } from "../session/event"
const plan = Agent.ID.make("plan")
const enter = `<system-reminder>
You are in Plan mode. You are not allowed to edit or create files, and you may not ask a subagent to do that either.
You are in Plan mode until the user switches agents. Plan mode is not changed by user intent, tone, or imperative language. If the user asks you to change files, do not edit. Tell them they need to switch agents.
</system-reminder>`
const leave = `<system-reminder>
You are NO LONGER in Plan mode. The previous Plan restrictions no longer apply. Any Plan mode instructions from earlier in this conversation are no longer active.
</system-reminder>`
export const Plugin = define({
id: "opencode.plan",
effect: Effect.fn(function* (ctx) {
yield* ctx.agent.transform((draft) => {
draft.update(plan, (item) => {
item.name = Agent.Name.make("Plan")
item.description = "Read-only agent for exploring the codebase and planning work before implementation."
item.mode = "primary"
item.permissions.push({ action: "question", resource: "*", effect: "allow" })
})
})
yield* ctx.tool.hook("execute.before", (event) => {
if (event.agent !== plan) return Effect.void
if (event.tool !== "edit" && event.tool !== "write" && event.tool !== "patch") return Effect.void
return new ToolFailure({
message: `Cannot use ${event.tool} in Plan mode. You are in a read-only mode and must not modify files.`,
})
})
yield* ctx.event.subscribe().pipe(
Stream.filter(
(event): event is SessionEvent.Created | SessionEvent.AgentSelected =>
event.type === "session.created" || event.type === "session.agent.selected",
),
Stream.runForEach((event) => {
const text = reminder(event)
if (!text) return Effect.void
return ctx.session
.synthetic({
sessionID: event.data.sessionID,
text,
resume: false,
})
.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to inject Plan mode reminder", { sessionID: event.data.sessionID, cause }),
),
)
}),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
function reminder(event: SessionEvent.Created | SessionEvent.AgentSelected) {
if (event.type === "session.created") {
if (event.data.agent !== plan) return
return enter
}
if (event.data.agent === event.data.previous) return
if (event.data.agent === plan) return enter
if (event.data.previous === plan) return leave
}
+13 -7
View File
@@ -1,10 +1,16 @@
import { createProviderPlugin } from "./factory"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
export const AlibabaPlugin = createProviderPlugin({
export const AlibabaPlugin = define({
id: "opencode.provider.alibaba",
package: "@ai-sdk/alibaba",
load: async (options) => {
const { createAlibaba } = await import("@ai-sdk/alibaba")
return createAlibaba(options)
},
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/alibaba") return
const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba"))
evt.sdk = mod.createAlibaba(evt.options)
}),
)
}),
})
+13 -7
View File
@@ -1,10 +1,16 @@
import { createProviderPlugin } from "./factory"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
export const CoherePlugin = createProviderPlugin({
export const CoherePlugin = define({
id: "opencode.provider.cohere",
package: "@ai-sdk/cohere",
load: async (options) => {
const { createCohere } = await import("@ai-sdk/cohere")
return createCohere(options)
},
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/cohere") return
const mod = yield* Effect.promise(() => import("@ai-sdk/cohere"))
evt.sdk = mod.createCohere(evt.options)
}),
)
}),
})
+13 -7
View File
@@ -1,10 +1,16 @@
import { createProviderPlugin } from "./factory"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
export const DeepInfraPlugin = createProviderPlugin({
export const DeepInfraPlugin = define({
id: "opencode.provider.deepinfra",
package: "@ai-sdk/deepinfra",
load: async (options) => {
const { createDeepInfra } = await import("@ai-sdk/deepinfra")
return createDeepInfra(options)
},
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/deepinfra") return
const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra"))
evt.sdk = mod.createDeepInfra(evt.options)
}),
)
}),
})
@@ -1,22 +0,0 @@
import { define } from "@opencode-ai/plugin/effect/plugin"
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
import { Effect } from "effect"
export function createProviderPlugin(input: {
readonly id: string
readonly package: string
readonly load: (options: AISDKHooks["sdk"]["options"]) => Promise<unknown>
}) {
return define({
id: input.id,
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== input.package) return
evt.sdk = yield* Effect.promise(() => input.load(evt.options))
}),
)
}),
})
}
+13 -7
View File
@@ -1,10 +1,16 @@
import { createProviderPlugin } from "./factory"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
export const GatewayPlugin = createProviderPlugin({
export const GatewayPlugin = define({
id: "opencode.provider.gateway",
package: "@ai-sdk/gateway",
load: async (options) => {
const { createGateway } = await import("@ai-sdk/gateway")
return createGateway(options)
},
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/gateway") return
const mod = yield* Effect.promise(() => import("@ai-sdk/gateway"))
evt.sdk = mod.createGateway(evt.options)
}),
)
}),
})
@@ -5,7 +5,6 @@ import { Credential } from "../../credential"
import { Bus } from "../../bus"
import { CopilotModels } from "../../github-copilot/models"
import { App } from "../../app"
import { Agent } from "../../agent"
import { Integration } from "../../integration"
import { Model } from "../../model"
import { define } from "@opencode-ai/plugin/effect/plugin"
@@ -243,10 +242,6 @@ export const GithubCopilotPlugin = define({
yield* ctx.session.hook("http.request", (evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.githubCopilot) return
if (evt.agent === Agent.ID.make("title"))
evt.request.headers.set("X-Interaction-Type", "conversation-background")
if (evt.agent === Agent.ID.make("compaction"))
evt.request.headers.set("X-Interaction-Type", "conversation-compaction")
const token = evt.request.headers.get("x-api-key")
if (!token) return
const text = yield* Effect.promise(() => evt.request.clone().text())
+13 -7
View File
@@ -1,10 +1,16 @@
import { createProviderPlugin } from "./factory"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
export const GroqPlugin = createProviderPlugin({
export const GroqPlugin = define({
id: "opencode.provider.groq",
package: "@ai-sdk/groq",
load: async (options) => {
const { createGroq } = await import("@ai-sdk/groq")
return createGroq(options)
},
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/groq") return
const mod = yield* Effect.promise(() => import("@ai-sdk/groq"))
evt.sdk = mod.createGroq(evt.options)
}),
)
}),
})
+13 -7
View File
@@ -1,10 +1,16 @@
import { createProviderPlugin } from "./factory"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
export const MistralPlugin = createProviderPlugin({
export const MistralPlugin = define({
id: "opencode.provider.mistral",
package: "@ai-sdk/mistral",
load: async (options) => {
const { createMistral } = await import("@ai-sdk/mistral")
return createMistral(options)
},
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/mistral") return
const mod = yield* Effect.promise(() => import("@ai-sdk/mistral"))
evt.sdk = mod.createMistral(evt.options)
}),
)
}),
})
@@ -1,10 +1,16 @@
import { createProviderPlugin } from "./factory"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
export const PerplexityPlugin = createProviderPlugin({
export const PerplexityPlugin = define({
id: "opencode.provider.perplexity",
package: "@ai-sdk/perplexity",
load: async (options) => {
const { createPerplexity } = await import("@ai-sdk/perplexity")
return createPerplexity(options)
},
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/perplexity") return
const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity"))
evt.sdk = mod.createPerplexity(evt.options)
}),
)
}),
})
@@ -1,10 +1,16 @@
import { createProviderPlugin } from "./factory"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
export const TogetherAIPlugin = createProviderPlugin({
export const TogetherAIPlugin = define({
id: "opencode.provider.togetherai",
package: "@ai-sdk/togetherai",
load: async (options) => {
const { createTogetherAI } = await import("@ai-sdk/togetherai")
return createTogetherAI(options)
},
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/togetherai") return
const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai"))
evt.sdk = mod.createTogetherAI(evt.options)
}),
)
}),
})
+13 -7
View File
@@ -1,10 +1,16 @@
import { createProviderPlugin } from "./factory"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
export const VenicePlugin = createProviderPlugin({
export const VenicePlugin = define({
id: "opencode.provider.venice",
package: "venice-ai-sdk-provider",
load: async (options) => {
const { createVenice } = await import("venice-ai-sdk-provider")
return createVenice(options)
},
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "venice-ai-sdk-provider") return
const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider"))
evt.sdk = mod.createVenice(evt.options)
}),
)
}),
})
+31 -5
View File
@@ -6,8 +6,12 @@ import { define, type Context } from "@opencode-ai/plugin/effect/plugin"
import { Effect } from "effect"
import { AbsolutePath } from "../schema"
import { Skill } from "../skill"
import { ConfigPluginSource } from "../config/plugin/source"
import { Config } from "../config"
import { Location } from "../location"
import { FSUtil } from "@opencode-ai/util/fs-util"
import os from "os"
import path from "path"
import { fileURLToPath } from "url"
import opencodeContent from "./skill/opencode.md" with { type: "text" }
import reportContent from "./skill/report.md" with { type: "text" }
@@ -68,10 +72,32 @@ const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDia
})
const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* () {
const sources = yield* ConfigPluginSource.Service
return (yield* sources.operations())
.map((operation) => (operation.type === "remove" ? `-${operation.target}` : operation.target))
.toSorted()
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
return yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") {
const directory = entry.path ? path.dirname(entry.path) : location.directory
return Effect.succeed(
(entry.info.plugins ?? []).map((item) => {
const ref = typeof item === "string" ? { package: item } : item
if (ref.package.startsWith("file://")) return fileURLToPath(ref.package)
if (ref.package.startsWith("./") || ref.package.startsWith("../")) return path.resolve(directory, ref.package)
return ref.package
}),
)
}
if (entry.type !== "directory") return Effect.succeed([])
return fs
.scan("{plugin,plugins}/*.{ts,js}", {
cwd: entry.path,
absolute: true,
include: "file",
dot: true,
symlink: true,
})
.pipe(Effect.orElseSucceed(() => []))
}).pipe(Effect.map((items) => items.flat().toSorted()))
})
function terminal() {
+1 -1
View File
@@ -30,7 +30,7 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
draft.update("exa", (integration) => (integration.name = "Exa"))
draft.method.update({
integrationID: "exa",
method: { type: "key" },
method: { type: "key", label: "API key (optional)" },
})
draft.method.update({
integrationID: "exa",
@@ -41,7 +41,7 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
draft.update("firecrawl", (integration) => (integration.name = "Firecrawl"))
draft.method.update({
integrationID: "firecrawl",
method: { type: "key" },
method: { type: "key", label: "API key (optional)" },
})
draft.method.update({
integrationID: "firecrawl",
@@ -56,7 +56,7 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
draft.update("parallel", (integration) => (integration.name = "Parallel"))
draft.method.update({
integrationID: "parallel",
method: { type: "key" },
method: { type: "key", label: "API key (optional)" },
})
draft.method.update({
integrationID: "parallel",
+15 -3
View File
@@ -2,7 +2,7 @@ export * as Project from "./project"
import { Context, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { asc, desc } from "drizzle-orm"
import { asc, desc, isNotNull, isNull, ne, or } from "drizzle-orm"
import path from "path"
import { AbsolutePath } from "./schema"
import { Database } from "./database/database"
@@ -13,7 +13,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Hash } from "@opencode-ai/util/hash"
import { ProjectDirectories } from "./project/directories"
import { ProjectSchema } from "./project/schema"
import { ProjectTable, upsertProject } from "./project/sql"
import { ProjectTable } from "./project/sql"
export const ID = ProjectSchema.ID
export type ID = ProjectSchema.ID
@@ -98,7 +98,19 @@ const layer = Layer.effect(
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* upsertProject(tx, project)
const vcs = project.vcs?.type
yield* tx
.insert(ProjectTable)
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
.onConflictDoUpdate({
target: ProjectTable.id,
set: { worktree: project.canonical, vcs: vcs ?? null },
setWhere: or(
ne(ProjectTable.worktree, project.canonical),
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
),
})
.run()
if (!project.vcs) return
yield* projectDirectories.create({ projectID: project.id, directory: project.canonical }, tx)
if (project.directory === project.canonical) return
-25
View File
@@ -1,14 +1,8 @@
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { isNotNull, isNull, ne, or } from "drizzle-orm"
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
import { absoluteArrayColumn, absoluteColumn } from "../database/path"
import { Timestamps } from "../database/schema.sql"
import type { AbsolutePath } from "../schema"
import { ProjectSchema } from "./schema"
type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
export const ProjectTable = sqliteTable("project", {
id: text().$type<ProjectSchema.ID>().primaryKey(),
worktree: absoluteColumn().notNull(),
@@ -39,22 +33,3 @@ export const ProjectDirectoryTable = sqliteTable(
},
(table) => [primaryKey({ columns: [table.project_id, table.directory] })],
)
export function upsertProject(
db: DatabaseClient | Transaction,
project: { readonly id: ProjectSchema.ID; readonly canonical: AbsolutePath; readonly vcs?: ProjectSchema.Vcs },
) {
const vcs = project.vcs?.type
return db
.insert(ProjectTable)
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
.onConflictDoUpdate({
target: ProjectTable.id,
set: { worktree: project.canonical, vcs: vcs ?? null },
setWhere: or(
ne(ProjectTable.worktree, project.canonical),
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
),
})
.run()
}
+19 -6
View File
@@ -3,7 +3,7 @@ export * from "./session/schema"
import { Effect, Layer, Schema, Context, Stream, Scope } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
import { and, asc, desc, eq, gt, isNotNull, isNull, like, lt, ne, or, type SQL } from "drizzle-orm"
import { Project } from "./project"
import { Workspace } from "./workspace"
import { Model } from "./model"
@@ -21,7 +21,7 @@ import { Agent } from "./agent"
import { Money } from "@opencode-ai/schema/money"
import { App } from "./app"
import { Slug } from "./util/slug"
import { upsertProject } from "./project/sql"
import { ProjectTable } from "./project/sql"
import path from "path"
import { fromRow } from "./session/info"
import { SessionRunner } from "./session/runner/index"
@@ -309,7 +309,22 @@ const layer = Layer.effect(
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const persistProject = (project: Project.Resolved) => upsertProject(db, project).pipe(Effect.orDie)
const persistProject = (project: Project.Resolved) => {
const vcs = project.vcs?.type
return db
.insert(ProjectTable)
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
.onConflictDoUpdate({
target: ProjectTable.id,
set: { worktree: project.canonical, vcs: vcs ?? null },
setWhere: or(
ne(ProjectTable.worktree, project.canonical),
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
),
})
.run()
.pipe(Effect.orDie)
}
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.mapError(
@@ -701,11 +716,10 @@ const layer = Layer.effect(
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
}),
switchAgent: Effect.fn("Session.switchAgent")(function* (input) {
const session = yield* result.get(input.sessionID)
yield* result.get(input.sessionID)
yield* bus.publish(SessionEvent.AgentSelected, {
sessionID: input.sessionID,
agent: input.agent,
previous: session.agent,
})
}),
switchModel: Effect.fn("Session.switchModel")(function* (input) {
@@ -719,7 +733,6 @@ const layer = Layer.effect(
yield* bus.publish(SessionEvent.ModelSelected, {
sessionID: input.sessionID,
model: input.model,
previous: session.model,
})
}),
rename: Effect.fn("Session.rename")(function* (input) {
+6 -25
View File
@@ -1,7 +1,6 @@
export * as SessionCompaction from "./compaction"
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest, type LanguageModel } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Context, Effect, Layer, Stream } from "effect"
@@ -12,17 +11,14 @@ import { llmClient } from "../effect/app-node-platform"
import { SessionEvent } from "./event"
import type { SessionMessage } from "./message"
import { SessionModelHeaders } from "./model-headers"
import { SessionModelHttp } from "./model-http"
import { SessionPromptCacheKey } from "./prompt-cache-key"
import { App } from "../app"
import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema"
import { toSessionError } from "./to-session-error"
import { Token } from "../util/token"
import type { Info, Ref } from "../model"
import type { Info } from "../model"
import { SessionUsage } from "./usage"
import { PluginHooks } from "../plugin/hooks"
import { Agent } from "../agent"
const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 15_000
@@ -70,18 +66,16 @@ type Dependencies = {
readonly app: App.Info
readonly bus: Bus.Interface
readonly llm: {
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, AIError>
}
readonly models: SessionRunnerModel.Interface
readonly config: Settings
readonly hooks: PluginHooks.Interface
}
export type AutoInput = {
readonly session: SessionSchema.Info
readonly messages: readonly SessionMessage.Info[]
readonly model: LanguageModel
readonly ref: Ref
readonly cost: Info["cost"]
}
@@ -91,12 +85,9 @@ export type ManualInput = {
readonly inputID: SessionMessage.ID
}
type RequiredInput = Omit<AutoInput, "ref">
type Plan = {
readonly session: SessionSchema.Info
readonly model: LanguageModel
readonly ref: Ref
readonly cost: Info["cost"]
readonly reason: SessionMessage.Compaction["reason"]
readonly prompt: string
@@ -109,7 +100,7 @@ export type Outcome =
| Pick<SessionMessage.CompactionFailed, "status" | "error">
export interface Interface {
readonly required: (input: RequiredInput) => boolean
readonly required: (input: AutoInput) => boolean
readonly compact: (input: AutoInput) => Effect.Effect<Outcome>
readonly compactManual: (input: ManualInput) => Effect.Effect<Outcome>
}
@@ -274,13 +265,6 @@ const make = (dependencies: Dependencies) => {
messages: [Message.user(plan.prompt)],
tools: [],
}),
{
http: SessionModelHttp.middleware(dependencies.hooks, {
sessionID: plan.session.id,
agent: Agent.ID.make("compaction"),
model: plan.ref,
}),
},
)
.pipe(
Stream.runForEach((event) => {
@@ -347,7 +331,6 @@ const make = (dependencies: Dependencies) => {
return yield* execute({
session: input.session,
model: input.model,
ref: input.ref,
cost: input.cost,
reason: "auto",
...content,
@@ -359,7 +342,7 @@ const make = (dependencies: Dependencies) => {
error,
})
})
const required = (input: RequiredInput) => {
const required = (input: AutoInput) => {
if (!config.auto) return false
const context = input.model.route.defaults.limits?.context
if (context === undefined || context <= 0) return false
@@ -402,7 +385,6 @@ const make = (dependencies: Dependencies) => {
return yield* execute({
session: input.session,
model: resolved.model,
ref: resolved.ref,
cost: resolved.cost,
reason: "manual",
inputID: input.inputID,
@@ -424,13 +406,12 @@ export const layer = Layer.effect(
const config = yield* Config.Service
const models = yield* SessionRunnerModel.Service
const app = yield* App.Metadata
const hooks = yield* PluginHooks.Service
return make({ bus, llm, models, config: settings(yield* config.entries()), app, hooks })
return make({ bus, llm, models, config: settings(yield* config.entries()), app })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, llmClient, Config.node, SessionRunnerModel.node, App.node, PluginHooks.node],
deps: [Bus.node, llmClient, Config.node, SessionRunnerModel.node, App.node],
})
+8 -20
View File
@@ -56,22 +56,16 @@ export const layer = Layer.effect(
),
Effect.asVoid,
)
// Write-ahead claim: starting records the durable intent that a turn is in flight, in the same
// transaction as the started event. Terminals release it — except shutdown interruption, which
// preserves the claim so the next server start resumes the turn. A claim that survives with no
// terminal is the signature of a process that died without teardown (crash, SIGKILL, eviction);
// recovery is a property of the database, never of a shutdown hook that may not run.
const claimOnCommit = (sessionID: SessionSchema.ID) => ({
commit: () => store.claim(sessionID),
})
const releaseOnCommit = (sessionID: SessionSchema.ID) => ({
commit: () => store.release(sessionID),
// Starting or finishing on its own clears stale suspension; interruption preserves it because
// managed-server teardown suspends active Sessions immediately before interrupting their drains.
const clearSuspensionOnCommit = (sessionID: SessionSchema.ID) => ({
commit: () => Effect.asVoid(store.consumeSuspended(sessionID)),
})
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
started: (sessionID) =>
reportLifecycle(
sessionID,
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
bus.publish(SessionEvent.Execution.Started, { sessionID }, clearSuspensionOnCommit(sessionID)),
),
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
const session = yield* store.get(sessionID)
@@ -92,17 +86,11 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const outcome = terminal(exit, reason)
if (outcome.type === "succeeded") {
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID }, releaseOnCommit(sessionID))
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID }, clearSuspensionOnCommit(sessionID))
return
}
if (outcome.type === "interrupted") {
// A user cancel (or a superseding execution) releases the claim: the turn must not
// resurrect at the next boot. Shutdown interruption keeps it for restart continuity.
yield* bus.publish(
SessionEvent.Execution.Interrupted,
{ sessionID, reason: outcome.reason },
outcome.reason === "shutdown" ? undefined : releaseOnCommit(sessionID),
)
yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: outcome.reason })
return
}
yield* bus.publish(
@@ -111,7 +99,7 @@ export const layer = Layer.effect(
sessionID,
error: outcome.error,
},
releaseOnCommit(sessionID),
clearSuspensionOnCommit(sessionID),
)
}),
),
+38 -88
View File
@@ -5,111 +5,61 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../../bus"
import { SessionEvent } from "../event"
import { SessionExecution } from "../execution"
import { SessionSchema } from "../schema"
import { SessionStore } from "../store"
const CONTINUE_AFTER_SERVER_RESTART =
"The server restarted while you were working. Continue from where you left off without repeating completed work."
const RESUME_EXHAUSTED = {
type: "aborted",
message: "Execution was interrupted repeatedly and will not be resumed automatically.",
} as const
export interface Options {
/**
* Times a single turn may be resumed before it is terminalized instead.
* The counter is durable and only a terminal event resets it, so a turn
* that keeps dying cannot crash-loop across restarts. Turns that complete
* never accumulate: the budget is per-turn, not per-session.
*/
readonly maxAttempts?: number
}
const DEFAULT_MAX_ATTEMPTS = 10
export interface Interface {
/**
* Resumes Sessions whose execution claim was never released turns orphaned
* by a process that died without teardown, or interrupted by a graceful
* shutdown (which preserves the claim on purpose). The claim is never
* cleared here: only a terminal event releases it, so a death anywhere in
* the resume path leaves the same orphaned claim for the next boot.
* Marks every execution active in this process for resumption by the next server start.
* Call once new work has stopped arriving and before teardown interrupts the drains.
*/
readonly suspendActiveSessions: Effect.Effect<void>
/** Resumes suspended Sessions. Each suspension is consumed atomically, so a Session resumes at most once. */
readonly resumeSuspendedSessions: Effect.Effect<void>
}
/**
* Recovery for orphaned executions. Claims are written at turn start by
* SessionExecution, so this sweep needs no cooperation from the previous
* process: crash, SIGKILL, isolate eviction, and graceful restart all leave
* the same durable signature.
*
* The sweep assumes every orphaned claim's owner is dead. The managed-server
* protocol guarantees this: a successor is only spawned after the previous
* process is confirmed dead (client service `kill`/`evict` poll the PID), the
* registration lock admits one managed server at a time, and unregistered
* servers sharing the database never sweep. The service is inert until called
* the managed server invokes it at boot; embedders may call it from their
* own start-up.
* Restart continuity actions for the managed server. The service is inert until called: only the
* managed server invokes it, so default, embedded, and stdio servers never suspend or auto-resume.
*/
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRestart") {}
export const layer = (options?: Options) =>
Layer.effect(
Service,
Effect.gen(function* () {
const store = yield* SessionStore.Service
const execution = yield* SessionExecution.Service
const bus = yield* Bus.Service
const scope = yield* Effect.scope
const maxAttempts = options?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS
const resumeOne = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
// Durable before the resume runs, so a crash inside the resumed turn is
// counted by the next sweep and the budget cannot be dodged.
const attempts = yield* store.countResume(sessionID)
if (attempts === undefined) return // the Session was deleted since listing
if (attempts > maxAttempts) {
// Terminalize instead: the release hook clears the claim and resets the
// counter atomically with the terminal event.
yield* bus.publish(
SessionEvent.Execution.Failed,
{ sessionID, error: RESUME_EXHAUSTED },
{ commit: () => store.release(sessionID) },
)
return
}
yield* bus.publish(SessionEvent.Synthetic, {
sessionID,
text: CONTINUE_AFTER_SERVER_RESTART,
description: "Continuing after restart",
})
// Forked into the service scope so boot never waits on resumed turns;
// resuming an already-live Session joins its execution. Drain failures
// are logged and durably recorded by the execution layer.
yield* execution.resume(sessionID).pipe(Effect.ignore, Effect.forkIn(scope))
})
return Service.of({
resumeSuspendedSessions: Effect.gen(function* () {
// Child claims never drive recovery (children are not resumed), so a
// dead child's claim is noise no terminal will ever release. Clearing
// is safe even against a live child: claims are recovery markers, not
// locks, and children are excluded from that recovery.
yield* store.releaseChildClaims
const active = yield* execution.active
// Sessions already draining in this process keep their claim; resuming
// them would only inject a stray continuation into a live turn.
const orphaned = (yield* store.listSuspended()).filter((sessionID) => !active.has(sessionID))
yield* Effect.forEach(orphaned, resumeOne, { concurrency: "unbounded", discard: true })
}),
})
}),
)
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const store = yield* SessionStore.Service
const execution = yield* SessionExecution.Service
const bus = yield* Bus.Service
return Service.of({
suspendActiveSessions: Effect.gen(function* () {
yield* store.suspend(yield* execution.active)
}),
resumeSuspendedSessions: Effect.gen(function* () {
const sessions = yield* store.listSuspended()
yield* Effect.forEach(
sessions,
(sessionID) =>
Effect.gen(function* () {
if (!(yield* store.consumeSuspended(sessionID))) return
yield* bus.publish(SessionEvent.Synthetic, {
sessionID,
text: CONTINUE_AFTER_SERVER_RESTART,
description: "Continuing after restart",
})
// Drain failures are already logged and durably recorded by the execution layer.
yield* Effect.ignore(execution.resume(sessionID))
}),
{ concurrency: "unbounded", discard: true },
)
}),
})
}),
)
export const node = makeGlobalNode({
service: Service,
layer: layer(),
layer,
deps: [SessionStore.node, SessionExecution.node, Bus.node],
})
+2 -2
View File
@@ -61,7 +61,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.usage.recorded": () => Effect.void,
"session.agent.selected": (event) => {
return Effect.gen(function* () {
const previous = event.data.previous ?? (yield* adapter.getAgent())
const previous = yield* adapter.getAgent()
yield* adapter.appendMessage(
SessionMessage.AgentSelected.make({
id: SessionMessage.ID.fromEvent(event.id),
@@ -76,7 +76,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
},
"session.model.selected": (event) => {
return Effect.gen(function* () {
const previous = event.data.previous ?? (yield* adapter.getModel())
const previous = yield* adapter.getModel()
yield* adapter.appendMessage(
SessionMessage.ModelSelected.make({
id: SessionMessage.ID.fromEvent(event.id),
-39
View File
@@ -1,39 +0,0 @@
export * as SessionModelHttp from "./model-http"
import type { StreamOptions } from "@opencode-ai/ai/route"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
import { Effect, Stream } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { PluginHooks } from "../plugin/hooks"
export const middleware =
(
hooks: PluginHooks.Interface,
input: { readonly sessionID: Session.ID; readonly agent: Agent.ID; readonly model: Model.Ref },
): NonNullable<StreamOptions["http"]> =>
(request, handler) =>
Effect.gen(function* () {
const before = yield* hooks.trigger("session", "http.request", {
...input,
request: yield* HttpClientRequest.toWeb(request),
})
let sent = HttpClientRequest.fromWeb(before.request)
if (before.request.body)
sent = HttpClientRequest.bodyUint8Array(
sent,
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())),
before.request.headers.get("content-type") ?? undefined,
)
const response = yield* handler(sent)
const after = yield* hooks.trigger("session", "http.response", {
...input,
request: before.request,
response: new Response(
[204, 205, 304].includes(response.status) ? null : yield* Stream.toReadableStreamEffect(response.stream),
{ status: response.status, headers: response.headers },
),
})
return HttpClientResponse.fromWeb(sent, after.response)
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))))
+32 -7
View File
@@ -4,7 +4,8 @@ import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import type { Content } from "@opencode-ai/schema/tool"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Cause, Config, Context, Effect, Layer, Result } from "effect"
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "../app"
import { Model } from "../model"
@@ -14,7 +15,6 @@ import { QuestionTool } from "../tool/plugin/question"
import { Tool } from "../tool"
import { SessionContext } from "./context"
import { SessionModelHeaders } from "./model-headers"
import { SessionModelHttp } from "./model-http"
import { SessionPromptCacheKey } from "./prompt-cache-key"
import { PromptCacheDiagnostics } from "./prompt-cache-diagnostics"
import { MAX_STEPS_PROMPT } from "./runner/max-steps"
@@ -227,11 +227,36 @@ export const layer = Layer.effect(
toolChoice: stepLimitReached ? "none" : undefined,
})
const options: StreamOptions = {
http: SessionModelHttp.middleware(hooks, {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
}),
http: (request, handler) =>
Effect.gen(function* () {
const before = yield* hooks.trigger("session", "http.request", {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
request: yield* HttpClientRequest.toWeb(request),
})
let sent = HttpClientRequest.fromWeb(before.request)
if (before.request.body)
sent = HttpClientRequest.bodyUint8Array(
sent,
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())),
before.request.headers.get("content-type") ?? undefined,
)
const response = yield* handler(sent)
const after = yield* hooks.trigger("session", "http.response", {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
request: before.request,
response: new Response(
[204, 205, 304].includes(response.status)
? null
: yield* Stream.toReadableStreamEffect(response.stream),
{ status: response.status, headers: response.headers },
),
})
return HttpClientResponse.fromWeb(sent, after.response)
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
}
if (promptCacheSnapshots) {
const current = PromptCacheDiagnostics.snapshot(request)
+1 -1
View File
@@ -244,7 +244,7 @@ const layer = Layer.effect(
const model = resolved.model
// Make room: history must fit the context window before the call. A pending manual
// compaction owns this instead; the runner executes it between steps.
const compactionInput = { session, messages: loaded.messages, model, ref: resolved.ref, cost: resolved.cost }
const compactionInput = { session, messages: loaded.messages, model, cost: resolved.cost }
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status === "completed")
@@ -65,15 +65,15 @@ const userAttachmentContent = (files: readonly FileAttachment[]) => {
)
if (eligible.length < 2) return files.flatMap(attachmentContent)
const seen = new Map<string, Set<string>>()
const seen = new Map<string, string[]>()
return files.flatMap((file) => {
if (!imageMimes.has(file.mime) || file.source.type !== "inline" || !file.mention?.text)
return attachmentContent(file)
const metadata = JSON.stringify([file.mime, file.name ?? null, file.description ?? null, file.mention.text])
const payloads = seen.get(metadata) ?? new Set<string>()
if (payloads.has(file.data)) return []
payloads.add(file.data)
seen.set(metadata, payloads)
const matches = seen.get(metadata)
if (matches?.includes(file.data)) return []
if (matches) matches.push(file.data)
if (!matches) seen.set(metadata, [file.data])
return attachmentContent(file)
})
}
-2
View File
@@ -58,9 +58,7 @@ export const SessionTable = sqliteTable(
...Timestamps,
time_compacting: integer(),
time_archived: integer(),
/** The execution claim timestamp (historical column name; see SessionStore.claim). */
time_suspended: integer(),
resume_attempts: integer().notNull().default(0),
},
(table) => [
index("session_v2_project_idx").on(table.project_id),
+22 -61
View File
@@ -1,6 +1,6 @@
export * as SessionStore from "./store"
import { and, eq, isNotNull, isNull, sql } from "drizzle-orm"
import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
@@ -17,32 +17,10 @@ export interface Interface {
readonly message: (
messageID: SessionMessage.ID,
) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Info } | undefined>
/**
* Top-level Sessions holding an execution claim. Child (subagent) Sessions
* are excluded: a resumed parent re-runs its tool call and spawns fresh
* children, so resuming orphaned children would duplicate their work.
*/
readonly listSuspended: () => Effect.Effect<ReadonlyArray<Session.ID>>
/**
* Records the execution claim: the durable write-ahead intent that a turn is
* (or was) in flight. Set when execution starts; a claim that survives to the
* next boot marks a turn that never completed its process crashed or shut
* down mid-turn.
*/
readonly claim: (sessionID: Session.ID) => Effect.Effect<void>
/** Releases the claim and resets resume accounting. Terminal events call this on commit. */
readonly release: (sessionID: Session.ID) => Effect.Effect<void>
/**
* Clears orphaned child (subagent) claims. Children are never resumed
* independently, so a dead child's claim is noise no terminal will ever
* release.
*/
readonly releaseChildClaims: Effect.Effect<void>
/**
* Durably counts one more resume of an orphaned claim, returning the new
* total or undefined when the Session no longer exists.
*/
readonly countResume: (sessionID: Session.ID) => Effect.Effect<number | undefined>
/** Clears suspension, reporting whether this caller consumed it. At most one concurrent caller receives true. */
readonly consumeSuspended: (sessionID: Session.ID) => Effect.Effect<boolean>
readonly suspend: (sessionIDs: Iterable<Session.ID>) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionStore") {}
@@ -79,52 +57,35 @@ const layer = Layer.effect(
return yield* db
.select({ sessionID: SessionTable.id })
.from(SessionTable)
.where(and(isNotNull(SessionTable.time_suspended), isNull(SessionTable.parent_id)))
.where(isNotNull(SessionTable.time_suspended))
.all()
.pipe(
Effect.orDie,
Effect.map((rows) => rows.map((row) => row.sessionID)),
)
}),
claim: Effect.fn("SessionStore.claim")(function* (sessionID) {
// The null guard makes re-claiming a still-claimed Session a zero-row
// no-op (a resumed turn re-claims through the same started hook).
// Claim bookkeeping never counts as user activity: time_updated is
// pinned so session ordering only moves on real changes.
consumeSuspended: Effect.fn("SessionStore.consumeSuspended")(function* (sessionID) {
return (
(yield* db
.update(SessionTable)
.set({ time_suspended: null })
.where(and(eq(SessionTable.id, sessionID), isNotNull(SessionTable.time_suspended)))
.returning({ sessionID: SessionTable.id })
.get()
.pipe(Effect.orDie)) !== undefined
)
}),
suspend: Effect.fn("SessionStore.suspend")(function* (sessionIDs) {
const ids = Array.from(sessionIDs)
if (ids.length === 0) return
// The null guard preserves the original suspension time if a Session is somehow suspended twice.
yield* db
.update(SessionTable)
.set({ time_suspended: Date.now(), time_updated: sql`${SessionTable.time_updated}` })
.where(and(eq(SessionTable.id, sessionID), isNull(SessionTable.time_suspended)))
.set({ time_suspended: Date.now() })
.where(and(inArray(SessionTable.id, ids), isNull(SessionTable.time_suspended)))
.run()
.pipe(Effect.orDie)
}),
release: Effect.fn("SessionStore.release")(function* (sessionID) {
yield* db
.update(SessionTable)
.set({ time_suspended: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
.where(eq(SessionTable.id, sessionID))
.run()
.pipe(Effect.orDie)
}),
releaseChildClaims: db
.update(SessionTable)
.set({ time_suspended: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
.where(and(isNotNull(SessionTable.time_suspended), isNotNull(SessionTable.parent_id)))
.run()
.pipe(Effect.orDie, Effect.asVoid, Effect.withSpan("SessionStore.releaseChildClaims")),
countResume: Effect.fn("SessionStore.countResume")(function* (sessionID) {
const row = yield* db
.update(SessionTable)
.set({
resume_attempts: sql`${SessionTable.resume_attempts} + 1`,
time_updated: sql`${SessionTable.time_updated}`,
})
.where(eq(SessionTable.id, sessionID))
.returning({ attempts: SessionTable.resume_attempts })
.get()
.pipe(Effect.orDie)
return row?.attempts
}),
})
}),
)
+3 -24
View File
@@ -1,7 +1,6 @@
export * as SessionTitle from "./title"
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import { Context, DateTime, Effect, Layer, Stream } from "effect"
import { Agent } from "../agent"
import { Database } from "../database/database"
@@ -10,11 +9,9 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { isExactRootFallback } from "@opencode-ai/util/session-title-fallback"
import { App } from "../app"
import { llmClient } from "../effect/app-node-platform"
import { PluginHooks } from "../plugin/hooks"
import { SessionEvent } from "./event"
import { SessionHistory } from "./history"
import { SessionModelHeaders } from "./model-headers"
import { SessionModelHttp } from "./model-http"
import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema"
import { SessionUsage } from "./usage"
@@ -27,12 +24,11 @@ type Dependencies = {
readonly app: App.Info
readonly bus: Bus.Interface
readonly llm: {
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, AIError>
}
readonly agents: Agent.Interface
readonly models: SessionRunnerModel.Interface
readonly store: SessionStore.Interface
readonly hooks: PluginHooks.Interface
}
export interface Interface {
@@ -89,13 +85,6 @@ const make = (dependencies: Dependencies) => {
messages: [Message.user(firstUser.text)],
tools: [],
}),
{
http: SessionModelHttp.middleware(dependencies.hooks, {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
}),
},
)
.pipe(
Stream.runForEach((event) => {
@@ -146,8 +135,7 @@ export const layer = Layer.effect(
const store = yield* SessionStore.Service
const database = yield* Database.Service
const app = yield* App.Metadata
const hooks = yield* PluginHooks.Service
const title = make({ bus, llm, agents, models, store, app, hooks })
const title = make({ bus, llm, agents, models, store, app })
return Service.of({
generateForFirstPrompt: (sessionID) => title.generateForFirstPrompt(database.db, sessionID),
})
@@ -157,14 +145,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [
Bus.node,
llmClient,
Agent.node,
SessionRunnerModel.node,
SessionStore.node,
Database.node,
App.node,
PluginHooks.node,
],
deps: [Bus.node, llmClient, Agent.node, SessionRunnerModel.node, SessionStore.node, Database.node, App.node],
})
+18 -3
View File
@@ -3,7 +3,7 @@ export * as SessionTransfer from "./transfer"
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
import { Tool } from "@opencode-ai/schema/tool"
import { Skill } from "@opencode-ai/schema/skill"
import { eq } from "drizzle-orm"
import { eq, isNotNull, isNull, ne, or } from "drizzle-orm"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import path from "path"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
@@ -12,7 +12,7 @@ import { Bus } from "../bus"
import { Database } from "../database/database"
import { Location } from "../location"
import { Project } from "../project"
import { upsertProject } from "../project/sql"
import { ProjectTable } from "../project/sql"
import { AbsolutePath, RelativePath } from "../schema"
import { Session } from "../session"
import { Slug } from "../util/slug"
@@ -49,7 +49,22 @@ const layer = Layer.effect(
const sessions = yield* Session.Service
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
const persistProject = (project: Project.Resolved) => upsertProject(db, project).pipe(Effect.orDie)
const persistProject = (project: Project.Resolved) => {
const vcs = project.vcs?.type
return db
.insert(ProjectTable)
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
.onConflictDoUpdate({
target: ProjectTable.id,
set: { worktree: project.canonical, vcs: vcs ?? null },
setWhere: or(
ne(ProjectTable.worktree, project.canonical),
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
),
})
.run()
.pipe(Effect.orDie)
}
return Service.of({
export: Effect.fn("SessionTransfer.export")(function* (input) {
+1 -1
View File
@@ -21,7 +21,7 @@ Usage notes:
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label`
export const Input = Schema.Struct({
questions: Schema.Array(Question.Prompt).check(Schema.isNonEmpty()).annotate({ description: "Questions to ask" }),
questions: Schema.NonEmptyArray(Question.Prompt).annotate({ description: "Questions to ask" }),
})
export const Output = Schema.Struct({
+1
View File
@@ -165,6 +165,7 @@ describe("Agent", () => {
"compaction",
"explore",
"general",
"plan",
"summary",
"title",
])
+2 -42
View File
@@ -123,16 +123,6 @@ describe("AISDKNative", () => {
expect(map("@ai-sdk/amazon-bedrock/mantle", settings, "openai.gpt-oss-safeguard-20b")?.package).toBe(
"@opencode-ai/ai/providers/amazon-bedrock/mantle/chat",
)
expect(
map(
"@ai-sdk/amazon-bedrock/mantle",
{
region: "us-west-2",
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1",
},
"openai.gpt-5.5",
),
).toMatchObject({ settings: { baseURL: "https://bedrock-mantle.us-west-2.api.aws/openai/v1" } })
})
test("maps static Bedrock Mantle credentials without leaking connection options", () => {
@@ -144,9 +134,8 @@ describe("AISDKNative", () => {
accessKeyId: "key",
secretAccessKey: "secret",
sessionToken: "session",
region: "eu-west-1",
},
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/v1",
region: "eu-west-1",
profile: "ignored",
credentialProvider: "ignored",
fetch: "ignored",
@@ -163,7 +152,7 @@ describe("AISDKNative", () => {
sessionToken: "session",
region: "eu-west-1",
},
baseURL: "https://bedrock-mantle.eu-west-1.api.aws/v1",
region: "eu-west-1",
providerOptions: { openai: { store: false } },
},
})
@@ -273,35 +262,6 @@ describe("AISDKNative", () => {
})
})
test("maps Vertex Anthropic settings to native Messages", () => {
expect(
map("@ai-sdk/google-vertex/anthropic", {
accessToken: "vertex-token",
baseURL: "https://vertex.example/v1",
headers: { "x-test": "value" },
location: "eu",
project: "vertex-project",
thinking: { type: "adaptive", display: "summarized" },
effort: "high",
}),
).toEqual({
package: "@opencode-ai/ai/providers/google-vertex/messages",
settings: {
accessToken: "vertex-token",
baseURL: "https://vertex.example/v1",
location: "eu",
project: "vertex-project",
providerOptions: {
anthropic: {
thinking: { type: "adaptive", display: "summarized" },
effort: "high",
},
},
},
headers: { "x-test": "value" },
})
})
test("maps supported xAI settings", () => {
expect(
map("@ai-sdk/xai", {
+4 -4
View File
@@ -1464,13 +1464,13 @@ describe("Config", () => {
])
expect(entries.filter((entry) => entry.type === "agents").map((entry) => entry.path)).toEqual([
AbsolutePath.make(globalAgents),
AbsolutePath.make(path.join(root, ".agents")),
AbsolutePath.make(path.join(directory, ".agents")),
AbsolutePath.make(path.join(root, ".agents")),
])
expect(entries.filter((entry) => entry.type === "claude").map((entry) => entry.path)).toEqual([
AbsolutePath.make(globalClaude),
AbsolutePath.make(path.join(root, ".claude")),
AbsolutePath.make(path.join(directory, ".claude")),
AbsolutePath.make(path.join(root, ".claude")),
])
expect(documents.map((document) => document.info.$schema)).toEqual([
"global",
@@ -1483,11 +1483,11 @@ describe("Config", () => {
])
expect(entries.map((entry) => (entry.type === "document" ? entry.info.$schema : entry.path))).toEqual([
AbsolutePath.make(globalClaude),
AbsolutePath.make(path.join(root, ".claude")),
AbsolutePath.make(path.join(directory, ".claude")),
AbsolutePath.make(path.join(root, ".claude")),
AbsolutePath.make(globalAgents),
AbsolutePath.make(path.join(root, ".agents")),
AbsolutePath.make(path.join(directory, ".agents")),
AbsolutePath.make(path.join(root, ".agents")),
"global",
AbsolutePath.make(global),
"outside",
-87
View File
@@ -168,75 +168,6 @@ describe("PluginSupervisor config", () => {
),
)
it.live("loads auto-discovered plugin package entrypoints in order", () =>
withLocation(
undefined,
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
expect(ids).toContain("package-exports")
expect(ids).toContain("package-module")
expect(ids).toContain("package-main")
expect(ids).toContain("package-index")
}),
false,
async (directory) => {
await Promise.all([
writeDiscoveredPackage(directory, "exports", { exports: "./entry.ts" }, { "entry.ts": "package-exports" }),
writeDiscoveredPackage(
directory,
"module",
{ exports: "./missing.js", module: "./entry.js" },
{ "entry.js": "package-module" },
),
writeDiscoveredPackage(
directory,
"main",
{ exports: { import: "./missing.js" }, module: "./missing.js", main: "./entry.js" },
{ "entry.js": "package-main" },
),
writeDiscoveredPackage(directory, "index", undefined, { "index.js": "package-index" }),
])
},
),
)
it.live("keeps auto-discovered package entrypoints inside the package directory", () =>
withLocation(
undefined,
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
expect(ids).toContain("contained-fallback")
expect(ids).toContain("symlink-fallback")
expect(ids).not.toContain("escaped-entrypoint")
}),
false,
async (directory) => {
await fs.mkdir(path.join(directory, ".opencode"), { recursive: true })
await fs.writeFile(path.join(directory, ".opencode", "escape.js"), discoveredPlugin("escaped-entrypoint"))
await writeDiscoveredPackage(
directory,
"contained",
{ exports: "../../escape.js" },
{ "index.js": "contained-fallback" },
)
await writeDiscoveredPackage(
directory,
"symlink",
{ exports: "./entry.js" },
{ "index.js": "symlink-fallback" },
)
await fs.symlink(
path.join(directory, ".opencode", "escape.js"),
path.join(directory, ".opencode", "plugins", "symlink", "entry.js"),
)
},
),
)
staticIt.live("uses only internal and SDK plugins when the static source is wired", () =>
Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service
@@ -458,21 +389,3 @@ export default Plugin.define({
})
`
}
function discoveredPlugin(id: string) {
return `export default { id: ${JSON.stringify(id)}, setup() {} }`
}
async function writeDiscoveredPackage(
directory: string,
name: string,
manifest: Record<string, unknown> | undefined,
files: Record<string, string>,
) {
const plugin = path.join(directory, ".opencode", "plugins", name)
await fs.mkdir(plugin, { recursive: true })
await Promise.all([
...(manifest ? [fs.writeFile(path.join(plugin, "package.json"), JSON.stringify(manifest))] : []),
...Object.entries(files).map(([file, id]) => fs.writeFile(path.join(plugin, file), discoveredPlugin(id))),
])
}
-53
View File
@@ -16,7 +16,6 @@ import { SkillFile } from "@opencode-ai/core/config/plugin/skill-file"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Bus } from "@opencode-ai/core/bus"
import { Credential } from "@opencode-ai/core/credential"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -24,8 +23,6 @@ import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Skill } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { WellKnown } from "@opencode-ai/core/wellknown"
import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
import { tmpdir } from "../fixture/tmpdir"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
@@ -94,25 +91,6 @@ const start = (skills: string[], directory: string) =>
directory,
)
const discover = (directory: string, global: string) =>
Effect.gen(function* () {
const config = yield* Config.Service
return yield* config.entries()
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
[
Location.node,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
],
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
[Credential.node, emptyCredentialNode],
[WellKnown.node, emptyWellknownNode],
[Watcher.node, Watcher.testLayer],
]),
),
)
function emitAndWait(update: Watcher.Update) {
return Effect.gen(function* () {
const watcher = yield* Watcher.Test
@@ -240,37 +218,6 @@ describe("ConfigSkillPlugin.Plugin", () => {
),
)
it.live("prefers a worktree skill over the parent checkout copy", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const checkout = path.join(tmp.path, "repo")
const worktree = path.join(checkout, ".worktrees", "feature")
const parentSkills = path.join(checkout, ".agents", "skills")
const worktreeSkills = path.join(worktree, ".agents", "skills")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(checkout, ".git"), { recursive: true })
await fs.mkdir(path.join(parentSkills, "review"), { recursive: true })
await fs.mkdir(path.join(worktreeSkills, "review"), { recursive: true })
await fs.writeFile(path.join(worktree, ".git"), "gitdir: ../../../.git/worktrees/feature\n")
await write(parentSkills, "review", "Parent checkout")
await write(worktreeSkills, "review", "Worktree")
})
const entries = yield* discover(worktree, path.join(tmp.path, "global"))
const skill = yield* startEntries(entries, worktree)
const review = (yield* skill.list()).find((item) => item.id === "review")
expect(review?.description).toBe("Worktree")
expect(review?.location).toBe(AbsolutePath.make(path.join(worktreeSkills, "review", "SKILL.md")))
}),
),
),
)
it.live("keeps directory skills when a URL source fails", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
+57 -3
View File
@@ -1,11 +1,15 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { Credential } from "@opencode-ai/core/credential"
import { Database } from "@opencode-ai/core/database/database"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Integration } from "@opencode-ai/core/integration"
import { testEffect } from "./lib/effect"
import { tmpdir } from "./fixture/tmpdir"
import path from "path"
const it = testEffect(LayerNode.compile(Credential.node))
const it = testEffect(Layer.empty)
const credentialLayer = LayerNode.compile(Credential.node)
describe("Credential", () => {
it.effect("stores, updates, lists, and removes credentials", () =>
@@ -31,6 +35,56 @@ describe("Credential", () => {
yield* credentials.remove(replacement.id)
expect(yield* credentials.list(integrationID)).toEqual([])
}),
}).pipe(Effect.provide(credentialLayer)),
)
it.effect("imports a read-only credential snapshot", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir("opencode-credential-snapshot-")),
(directory) => Effect.promise(() => directory[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((directory) => {
const filename = path.join(directory.path, "source.sqlite")
const source = LayerNode.compile(Credential.node, [[Database.node, Database.configured({ path: filename })]])
const keyIntegration = Integration.ID.make("openai")
const oauthIntegration = Integration.ID.make("github-copilot")
return Effect.gen(function* () {
yield* Effect.gen(function* () {
const credentials = yield* Credential.Service
yield* credentials.create({
integrationID: keyIntegration,
value: Credential.Key.make({ type: "key", key: "secret" }),
})
yield* credentials.create({
integrationID: oauthIntegration,
value: Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("oauth"),
access: "access",
refresh: "refresh",
expires: 1,
}),
})
}).pipe(Effect.provide(source), Effect.scoped)
yield* Effect.gen(function* () {
yield* Credential.importFromDatabase({ path: filename })
const credentials = yield* Credential.Service
expect((yield* credentials.list(keyIntegration))[0]?.value).toEqual(
Credential.Key.make({ type: "key", key: "secret" }),
)
expect((yield* credentials.list(oauthIntegration))[0]?.value).toEqual(
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("oauth"),
access: "access",
refresh: "",
expires: 8640000000000000,
}),
)
}).pipe(Effect.provide(LayerNode.compile(LayerNode.group([Credential.node, Database.node]))), Effect.scoped)
})
}),
),
)
})
@@ -84,19 +84,6 @@ describe("DatabaseMigration", () => {
).rejects.toThrow("Database is not empty and has no session table")
})
test("bootstraps alongside underscore-prefixed embedder tables", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE _embedder_state (id text PRIMARY KEY)`)
yield* DatabaseMigration.apply(db)
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_v2'`)).toEqual(
{ name: "session_v2" },
)
}),
)
})
test("applies generic migrations once and records their order", async () => {
await run(
Effect.gen(function* () {
@@ -1,7 +1,7 @@
import fs from "fs/promises"
import os from "os"
import { Effect } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { EffectFlock } from "@opencode-ai/util/effect-flock"
import { Global } from "@opencode-ai/util/global"
@@ -30,7 +30,7 @@ const testGlobal = Global.layerWith({
log: os.tmpdir(),
})
const testLayer = LayerNode.compile(EffectFlock.node, [[Global.node, testGlobal]])
const testLayer = AppNodeBuilder.build(EffectFlock.node, [[Global.node, testGlobal]])
async function job() {
if (msg.ready) await fs.writeFile(msg.ready, String(process.pid))
@@ -27,32 +27,8 @@ test("defensively syncs advertised Copilot models", async () => {
max_context_window_tokens: 200000,
max_output_tokens: 16384,
max_prompt_tokens: 180000,
vision: {
max_prompt_image_size: 10000000,
max_prompt_images: 10,
supported_media_types: ["image/png", "application/pdf"],
},
},
supports: { tool_calls: true, vision: true, reasoning_effort: ["low", "high"] },
},
},
{
model_picker_enabled: true,
id: "vision-only",
name: "Vision only",
version: "vision-only-2026-06-01",
capabilities: {
family: "vision",
limits: {
max_output_tokens: 16384,
max_prompt_tokens: 180000,
vision: {
max_prompt_image_size: 10000000,
max_prompt_images: 10,
supported_media_types: ["image/png"],
},
},
supports: { tool_calls: true, vision: true },
supports: { tool_calls: true, reasoning_effort: ["low", "high"] },
},
},
{
@@ -91,8 +67,6 @@ test("defensively syncs advertised Copilot models", async () => {
Model.VariantID.make("low"),
Model.VariantID.make("high"),
])
expect(model?.capabilities.input).toEqual(["text", "image", "pdf"])
expect(models.get(Model.ID.make("vision-only"))?.capabilities.input).toEqual(["text", "image"])
expect(models.get(Model.ID.make("utility"))?.enabled).toBe(false)
expect(models.has(Model.ID.make("stale"))).toBe(false)
expect(models.has(Model.ID.make("incomplete"))).toBe(false)
@@ -24,7 +24,6 @@ const it = testEffect(Layer.empty)
const instructionLayer = (input: {
config?: string
home?: string
locationServiceLayer: Layer.Layer<Location.Service>
filesystemLayer?: Layer.Layer<FSUtil.Service>
project?: boolean
@@ -35,15 +34,7 @@ const instructionLayer = (input: {
LayerNode.group([InstructionDiscovery.node, Bus.node, FSUtil.node, Global.node, Location.node, Watcher.node]),
[
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })],
[
Global.node,
input.config || input.home
? Global.layerWith({
...(input.config ? { config: input.config } : {}),
...(input.home ? { home: input.home } : {}),
})
: tempGlobalLayer,
],
[Global.node, input.config ? Global.layerWith({ config: input.config }) : tempGlobalLayer],
[Location.node, input.locationServiceLayer],
[Watcher.node, watcher],
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
@@ -121,13 +112,10 @@ describe("ConfigInstructionPlugin.Plugin", () => {
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const home = path.join(tmp.path, "home")
const shared = path.join(home, "code")
const project = path.join(shared, "repo")
const project = path.join(tmp.path, "project")
const directory = path.join(project, "packages", "core")
const outside = path.join(tmp.path, "AGENTS.md")
const globalFile = path.join(global, "AGENTS.md")
const sharedFile = path.join(shared, "AGENTS.md")
const projectFile = path.join(project, "AGENTS.md")
const packageFile = path.join(directory, "AGENTS.md")
return Effect.gen(function* () {
@@ -136,7 +124,6 @@ describe("ConfigInstructionPlugin.Plugin", () => {
await fs.mkdir(directory, { recursive: true })
await fs.writeFile(outside, "outside")
await fs.writeFile(globalFile, "global")
await fs.writeFile(sharedFile, "shared")
await fs.writeFile(projectFile, "project")
await fs.writeFile(packageFile, "package")
})
@@ -148,20 +135,13 @@ describe("ConfigInstructionPlugin.Plugin", () => {
{ path: packageFile, type: "file" },
{ path: path.join(project, "packages", "AGENTS.md"), type: "file" },
{ path: projectFile, type: "file" },
{ path: sharedFile, type: "file" },
{ path: path.join(home, "AGENTS.md"), type: "file" },
])
expect(yield* watcher.subscriptions()).not.toContainEqual({
path: path.join(tmp.path, "AGENTS.md"),
type: "file",
})
const initialized = yield* readInitial(yield* discovery.load())
expect(initialized.text).toBe(
[
`Instructions from: ${globalFile}\nglobal`,
`Instructions from: ${packageFile}\npackage`,
`Instructions from: ${projectFile}\nproject`,
`Instructions from: ${sharedFile}\nshared`,
].join("\n\n"),
)
expect(initialized.text).not.toContain("outside")
@@ -179,7 +159,6 @@ describe("ConfigInstructionPlugin.Plugin", () => {
"These instructions replace all previously loaded ambient instructions.",
`Instructions from: ${globalFile}\nglobal`,
`Instructions from: ${projectFile}\nproject`,
`Instructions from: ${sharedFile}\nshared`,
].join("\n\n"),
)
@@ -187,8 +166,6 @@ describe("ConfigInstructionPlugin.Plugin", () => {
yield* emitAndWait({ type: "delete", path: globalFile })
yield* Effect.promise(() => fs.rm(projectFile))
yield* emitAndWait({ type: "delete", path: projectFile })
yield* Effect.promise(() => fs.rm(sharedFile))
yield* emitAndWait({ type: "delete", path: sharedFile })
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
"Previously loaded instructions no longer apply.",
)
@@ -196,7 +173,6 @@ describe("ConfigInstructionPlugin.Plugin", () => {
Effect.provide(
instructionLayer({
config: global,
home,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
@@ -239,17 +215,15 @@ describe("ConfigInstructionPlugin.Plugin", () => {
),
)
it.live("discovers a newly created instruction file above the project root", () =>
it.live("discovers a newly created instruction file in an intermediate directory", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const home = path.join(tmp.path, "home")
const shared = path.join(home, "code")
const project = path.join(shared, "repo")
const intermediate = path.join(shared, "AGENTS.md")
const directory = path.join(project, "core")
const project = path.join(tmp.path, "project")
const intermediate = path.join(project, "packages", "AGENTS.md")
const directory = path.join(project, "packages", "core")
const projectFile = path.join(project, "AGENTS.md")
return Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
@@ -261,7 +235,7 @@ describe("ConfigInstructionPlugin.Plugin", () => {
yield* emitAndWait({ type: "create", path: intermediate })
expect((yield* readInitial(yield* discovery.load())).text).toBe(
[`Instructions from: ${projectFile}\nproject`, `Instructions from: ${intermediate}\nintermediate`].join(
[`Instructions from: ${intermediate}\nintermediate`, `Instructions from: ${projectFile}\nproject`].join(
"\n\n",
),
)
@@ -269,48 +243,6 @@ describe("ConfigInstructionPlugin.Plugin", () => {
Effect.provide(
instructionLayer({
config: path.join(tmp.path, "global"),
home,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(project) },
),
),
),
}),
),
)
}),
),
)
it.live("stops instruction candidates at the project root outside home", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const home = path.join(tmp.path, "home")
const project = path.join(tmp.path, "scratch", "repo")
const directory = path.join(project, "packages", "core")
return Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
yield* start()
const watcher = yield* Watcher.Test
expect(yield* watcher.subscriptions()).toEqual([
{ path: path.join(global, "AGENTS.md"), type: "file" },
{ path: path.join(directory, "AGENTS.md"), type: "file" },
{ path: path.join(project, "packages", "AGENTS.md"), type: "file" },
{ path: path.join(project, "AGENTS.md"), type: "file" },
])
}).pipe(
Effect.provide(
instructionLayer({
config: global,
home,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
+1 -167
View File
@@ -123,24 +123,6 @@ describe("ModelResolver", () => {
}),
)
it.effect("resolves environment templates before native providers inspect endpoints", () =>
withEnv({ AZURE_HOST: "resource.openai.azure.com" }, () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/azure"), {
providerID: Provider.ID.azure,
settings: { baseURL: "https://${AZURE_HOST}/openai" },
}),
)
expect(resolved.route.endpoint).toMatchObject({
baseURL: "https://resource.openai.azure.com/openai/v1",
query: { "api-version": "v1" },
})
}),
),
)
it.effect("maps Bedrock Mantle models to native Responses and safeguards to Chat", () =>
Effect.gen(function* () {
const credential = Credential.Key.make({ type: "key", key: "secret" })
@@ -170,46 +152,6 @@ describe("ModelResolver", () => {
}),
)
it.effect("resolves Bedrock Mantle catalog endpoints from the configured region", () =>
withEnv({ AWS_REGION: undefined }, () =>
Effect.gen(function* () {
const catalog = model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
providerID: Provider.ID.amazonBedrock,
modelID: "openai.gpt-5.5",
settings: {
region: "us-west-2",
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1",
},
})
const resolved = yield* ModelResolver.fromCatalogModel(catalog)
expect(resolved.route).toMatchObject({
id: "bedrock-mantle-responses",
endpoint: { baseURL: "https://bedrock-mantle.us-west-2.api.aws/openai/v1" },
})
expect(catalog.settings?.baseURL).toBe("https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1")
}),
),
)
it.effect("prefers the configured Mantle region over the environment", () =>
withEnv({ AWS_REGION: "us-east-1" }, () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
modelID: "openai.gpt-5.5",
settings: {
region: "us-west-2",
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1",
},
}),
)
expect(resolved.route.endpoint.baseURL).toBe("https://bedrock-mantle.us-west-2.api.aws/openai/v1")
}),
),
)
it.effect("uses the API modelID instead of the catalog ID for native OpenAI routes", () =>
Effect.gen(function* () {
const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
@@ -323,7 +265,7 @@ describe("ModelResolver", () => {
),
)
it.effect("rejects unresolved variables in constructed provider routes", () =>
it.effect("rejects unresolved provider URL variables before route construction", () =>
withEnv({ REQUIRED_HOST: undefined }, () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
@@ -763,57 +705,6 @@ describe("ModelResolver", () => {
}),
)
it.effect("routes Vertex Anthropic catalog models through native Messages", () =>
Effect.gen(function* () {
const native = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/openai")))
const credential = Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("device"),
access: "vertex-token",
refresh: "refresh",
expires: Date.now() + 60_000,
})
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/google-vertex/anthropic"), {
modelID: "claude-sonnet-4-6",
settings: {
location: "eu",
project: "vertex-project",
thinking: { type: "adaptive", display: "summarized" },
effort: "high",
},
}),
credential,
{
loadPackage: (specifier) => {
expect(specifier).toBe("@opencode-ai/ai/providers/google-vertex/messages")
return Effect.succeed({
model: (modelID, settings) => {
expect(modelID).toBe("claude-sonnet-4-6")
expect(settings).toMatchObject({
accessToken: "vertex-token",
location: "eu",
project: "vertex-project",
providerOptions: {
anthropic: {
thinking: { type: "adaptive", display: "summarized" },
effort: "high",
},
},
})
return LanguageModel.make({ id: modelID, provider: "native-provider", route: native.route })
},
})
},
loadAISDK: () => Effect.die("AI SDK loader should not be called"),
},
)
expect(resolved).toMatchObject({ id: "claude-sonnet-4-6", provider: "test-provider" })
}),
)
it.effect("merges mapped OpenRouter headers and body with catalog overlays", () =>
ModelResolver.fromCatalogModel(
model(Provider.aisdk("@openrouter/ai-sdk-provider"), {
@@ -932,63 +823,6 @@ describe("ModelResolver", () => {
}),
)
it.effect("rejects unresolved variables before loading opaque AISDK packages", () =>
withEnv({ REQUIRED_HOST: undefined }, () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/mistral"), {
settings: { baseURL: "https://${REQUIRED_HOST}/v1" },
}),
undefined,
{ loadAISDK: () => Effect.die("AI SDK loader should not be called") },
).pipe(Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.UnresolvedProviderVariablesError",
variables: ["REQUIRED_HOST"],
})
}),
),
)
it.effect("rejects placeholders introduced by environment expansion before loading providers", () =>
withEnv({ PROVIDER_HOST: "${MISSING_HOST}", MISSING_HOST: undefined }, () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/mistral"), {
settings: { baseURL: "https://${PROVIDER_HOST}/v1" },
}),
undefined,
{ loadAISDK: () => Effect.die("AI SDK loader should not be called") },
).pipe(Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.UnresolvedProviderVariablesError",
variables: ["MISSING_HOST"],
})
}),
),
)
it.effect("rejects unresolved variables before loading native provider packages", () =>
withEnv({ REQUIRED_HOST: undefined }, () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/google"), {
settings: { baseURL: "https://${REQUIRED_HOST}/v1" },
}),
undefined,
{ loadPackage: () => Effect.die("Native package loader should not be called") },
).pipe(Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.UnresolvedProviderVariablesError",
variables: ["REQUIRED_HOST"],
})
}),
),
)
it.effect("rejects AISDK packages without an available loader", () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
+2 -1
View File
@@ -4,6 +4,7 @@ import { Catalog } from "@opencode-ai/core/catalog"
import { Command } from "@opencode-ai/core/command"
import { Config } from "@opencode-ai/core/config"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
@@ -34,7 +35,7 @@ const npmLayer = Layer.succeed(
}),
)
export const PluginTestLayer = LayerNode.compile(
export const PluginTestLayer = AppNodeBuilder.build(
LayerNode.group([
FileSystem.node,
FSUtil.node,
@@ -215,66 +215,6 @@ describe("ModelsDevPlugin", () => {
}),
)
it.effect("omits deprecated models from the catalog", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("acme")
const activeID = Model.ID.make("current")
const deprecatedID = Model.ID.make("legacy")
const model = {
modelID: activeID,
providerID,
name: "Current",
capabilities: { tools: true, input: [], output: [] },
variants: [],
time: { released: Date.parse("2026-01-01") },
cost: [],
status: "active",
enabled: true,
limit: { context: 128_000, output: 32_000 },
} satisfies Omit<Model.Info, "id">
const snapshots = [
{
info: {
id: providerID,
name: "Acme",
package: Provider.aisdk("@ai-sdk/openai-compatible"),
},
environment: [],
models: [
{ id: activeID, ...model },
{
id: deprecatedID,
...model,
modelID: deprecatedID,
name: "Legacy",
status: "deprecated" as const,
},
],
},
] satisfies readonly ModelsDev.Snapshot[]
yield* ModelsDevPlugin.effect(
host({
catalog: catalogHost(catalog),
integration: integrationHost(integrations),
}),
).pipe(
Effect.provideService(
ModelsDev.Service,
ModelsDev.Service.of({
get: () => Effect.succeed(snapshots),
refresh: () => Effect.void,
}),
),
)
expect(yield* catalog.model.get(providerID, activeID)).toBeDefined()
expect(yield* catalog.model.get(providerID, deprecatedID)).toBeUndefined()
}),
)
it.effect("registers key methods for providers with environment variables", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
@@ -141,32 +141,6 @@ describe("GithubCopilotPlugin", () => {
}),
)
it.effect("classifies title generation as a background interaction", () =>
Effect.gen(function* () {
yield* addPlugin()
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
sessionID: Session.ID.make("ses_title"),
agent: Agent.ID.make("title"),
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4-nano") }),
request: new Request("https://api.githubcopilot.com/chat/completions"),
})
expect(event.request.headers.get("x-interaction-type")).toBe("conversation-background")
}),
)
it.effect("classifies compaction requests", () =>
Effect.gen(function* () {
yield* addPlugin()
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
sessionID: Session.ID.make("ses_compaction"),
agent: Agent.ID.make("compaction"),
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4") }),
request: new Request("https://api.githubcopilot.com/responses"),
})
expect(event.request.headers.get("x-interaction-type")).toBe("conversation-compaction")
}),
)
it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
+16 -34
View File
@@ -1,18 +1,18 @@
import { describe, expect } from "bun:test"
import { NodeFileSystem } from "@effect/platform-node"
import { Config } from "@opencode-ai/core/config"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
import { Effect, Layer, Stream } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location"
import { Effect, Stream } from "effect"
import { SkillPlugin } from "@opencode-ai/core/plugin/skill"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Skill } from "@opencode-ai/core/skill"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { host } from "./host"
const it = testEffect(AppNodeBuilder.build(Skill.node))
const sources = (operations: readonly ConfigPluginSource.Operation[] = []) =>
Layer.succeed(
ConfigPluginSource.Service,
ConfigPluginSource.Service.of({ operations: () => Effect.succeed(operations), changes: () => Stream.never }),
)
describe("SkillPlugin.Plugin", () => {
it.effect("registers built-in skills", () =>
@@ -27,7 +27,15 @@ describe("SkillPlugin.Plugin", () => {
reload: skill.reload,
},
}),
).pipe(Effect.provide(sources()))
).pipe(
Effect.provide(Config.testLayer()),
Effect.provideService(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })),
),
Effect.provide(AppNodeBuilder.build(FSUtil.node)),
Effect.provide(NodeFileSystem.layer),
)
const skills = yield* skill.list()
const report = skills.find((item) => item.id === "report")
@@ -50,30 +58,4 @@ describe("SkillPlugin.Plugin", () => {
expect(report?.content).toContain("- install/channel: beta")
}),
)
it.effect("reports canonical configured plugin sources with existing labels and ordering", () =>
Effect.gen(function* () {
const skill = yield* Skill.Service
yield* SkillPlugin.Plugin.effect(
host({
skill: {
list: () => Effect.die("unused skill.list"),
transform: skill.transform,
reload: skill.reload,
},
}),
)
const report = (yield* skill.list()).find((item) => item.id === "report")
expect(report?.content).toContain("- Active plugins: -disabled, local.ts, package-plugin, package-plugin")
}).pipe(
Effect.provide(
sources([
{ type: "add", target: "package-plugin", options: {} },
{ type: "remove", target: "disabled" },
{ type: "add", target: "local.ts", options: {}, mtime: 1 },
{ type: "add", target: "package-plugin", options: { enabled: true } },
]),
),
),
)
})

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