Compare commits

..

15 Commits

Author SHA1 Message Date
Kit Langton 404701baac fix(util): defer module-scope randomness, I/O, and import.meta paths 2026-08-11 10:45:45 -04:00
Kit Langton 026e0c634f refactor(core): reuse plugin directory names (#41754) 2026-08-11 10:40:36 -04:00
Kit Langton c581b59b7f refactor(merman): simplify spatial span assembly (#41757) 2026-08-11 10:40:22 -04:00
Kit Langton 5cc7811ec2 refactor(core): defer database global lookup (#41755) 2026-08-11 10:40:03 -04:00
Kit Langton 5d32845b02 refactor(tui): simplify marquee reset (#41758) 2026-08-11 10:39:51 -04:00
Kit Langton e9f5842f29 refactor: simplify attachment identity tracking (#41756) 2026-08-11 10:39:42 -04:00
Kit Langton 7b9734593a refactor(core): generate static imports for the migration manifest (#41657) 2026-08-11 10:35:19 -04:00
opencode-agent[bot] 1dbfc7cfab chore: generate 2026-08-11 14:12:14 +00:00
Kit Langton 3441b95afc fix(core): models.dev catalog population must survive KV cache write failures (#41735) 2026-08-11 10:09:37 -04:00
opencode-agent[bot] 306204bad8 fix(app): handle untitled session tab info (#41700)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-11 18:02:10 +10:00
Aiden Cline 47c8d85904 feat(plugin): allow tool hooks to fail with tool errors (#41668) 2026-08-11 00:36:59 -05:00
Aiden Cline c401076b6f fix(core): preserve AI SDK tool media (#41672) 2026-08-11 00:33:33 -05:00
Luke Parker 1aef4de853 chore(desktop): skip unused beta CLI builds (#41673) 2026-08-11 15:07:21 +10:00
opencode-agent[bot] 9c94634515 fix(tui): use active model for compaction (#41608)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-10 23:50:51 -05:00
Luke Parker c2b754bb03 feat(desktop): publish v2 beta desktop (#41626) 2026-08-11 14:33:31 +10:00
37 changed files with 459 additions and 243 deletions
+13 -10
View File
@@ -75,7 +75,7 @@ jobs:
build-cli:
needs: version
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.repository == 'anomalyco/opencode'
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
with:
@@ -91,7 +91,7 @@ jobs:
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Build legacy CLI
if: github.ref_name != 'v2'
if: github.ref_name != 'v2' && github.ref_name != 'beta'
run: ./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
@@ -109,7 +109,7 @@ jobs:
GH_TOKEN: ${{ steps.committer.outputs.token }}
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: github.ref_name != 'v2'
if: github.ref_name != 'v2' && github.ref_name != 'beta'
with:
name: opencode-cli
path: |
@@ -117,7 +117,7 @@ jobs:
packages/opencode/dist/opencode-linux*
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: github.ref_name != 'v2'
if: github.ref_name != 'v2' && github.ref_name != 'beta'
with:
name: opencode-cli-windows
path: packages/opencode/dist/opencode-windows*
@@ -132,7 +132,7 @@ jobs:
build-node-cli:
needs: version
if: github.repository == 'anomalyco/opencode'
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
strategy:
fail-fast: false
matrix:
@@ -184,7 +184,7 @@ jobs:
- build-cli
- version
runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2' && github.ref_name != 'beta'
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
@@ -377,7 +377,7 @@ jobs:
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
RUST_TARGET: ${{ matrix.settings.target }}
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
- name: Build
run: bun run build
@@ -393,6 +393,7 @@ jobs:
VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }}
VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
- name: Package
if: needs.version.outputs.release
@@ -496,29 +497,31 @@ jobs:
registry-url: "https://registry.npmjs.org"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2'
if: github.ref_name != 'v2' && github.ref_name != 'beta'
with:
name: opencode-cli
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2'
if: github.ref_name != 'v2' && github.ref_name != 'beta'
with:
name: opencode-cli-windows
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2'
if: github.ref_name != 'v2' && github.ref_name != 'beta'
with:
name: opencode-cli-signed-windows
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'beta'
with:
name: opencode-preview-cli
path: packages/cli/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'beta'
with:
pattern: opencode-node-cli-*
path: packages/cli/dist/node
+1
View File
@@ -439,6 +439,7 @@
"@actions/artifact": "4.0.0",
"@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:",
+2 -2
View File
@@ -351,8 +351,8 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const key = tabKey(tab)
const next = { title: session.title, directory: session.location.directory }
const current = info[key]
console.log({ tab, session, current })
if (current?.title === next.title && current.directory === next.directory) return
if (current && current.title === next.title && current.directory === next.directory) return
console.debug("[tabs] update persisted session info", { key, sessionID: session.id, current, next })
setInfo(key, next)
},
select: navigateTab,
+4 -5
View File
@@ -194,11 +194,10 @@ 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: DatabaseMigration.Migration[] = (
await Promise.all([
${names.map((name) => ` import("./migration/${name}"),`).join("\n")}
])
).map((module) => module.default)
export const migrations = [
${names.map((_, index) => ` m${index.toString().padStart(2, "0")},`).join("\n")}
] satisfies DatabaseMigration.Migration[]
`
}
+16 -1
View File
@@ -508,8 +508,23 @@ function toolOutput(result: ToolResultValue) {
case "text":
case "error":
return { type: "text" as const, value: messageValue(result.value) }
case "content":
return {
type: "content" as const,
value: result.value.map((item) => {
if (item.type === "text") return { type: "text" as const, text: item.text }
const data = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(item.uri)?.[1]
const image = item.mime.toLowerCase().startsWith("image/")
if (data !== undefined)
return image
? { type: "image-data" as const, data, mediaType: item.mime }
: { type: "file-data" as const, data, mediaType: item.mime, filename: item.name }
return image ? { type: "image-url" as const, url: item.uri } : { type: "file-url" as const, url: item.uri }
}),
}
case "json":
return { type: "json" as const, value: jsonValue(result.value) }
}
return { type: "json" as const, value: jsonValue(result.value) }
}
function tool(input: ToolDefinition): LanguageModelV3FunctionTool {
+3 -3
View File
@@ -153,10 +153,12 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
})
})
const sourceDirectories = ["plugin", "plugins"] as const
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const files = yield* fs
.scan("{plugin,plugins}/*.{ts,js}", {
.scan(`{${sourceDirectories.join(",")}}/*.{ts,js}`, {
cwd: directory,
absolute: true,
include: "file",
@@ -168,8 +170,6 @@ function discoverDirectory(fs: FSUtil.Interface, directory: string) {
})
}
const sourceDirectories = ["plugin", "plugins"] as const
function isPluginSource(entries: readonly Entry[], file: string) {
return entries.some(
(entry) =>
+1 -1
View File
@@ -42,10 +42,10 @@ const databaseLayer = Layer.effect(
export function layer(options: Options = { path: ":memory:" }) {
return Layer.unwrap(
Effect.gen(function* () {
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))
}),
)
+84 -45
View File
@@ -1,47 +1,86 @@
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"
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)
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,
] satisfies DatabaseMigration.Migration[]
@@ -285,8 +285,9 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
cacheWrite: undefined,
},
outputTokens: {
...outputUsage(responseBody.usage),
total: responseBody.usage?.completion_tokens ?? undefined,
text: undefined,
reasoning: responseBody.usage?.completion_tokens_details?.reasoning_tokens ?? undefined,
},
raw: responseBody.usage ?? undefined,
},
@@ -356,7 +357,6 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
cachedTokens: number | undefined
}
totalTokens: number | undefined
rawCompletionTokens: number | undefined
} = {
completionTokens: undefined,
completionTokensDetails: {
@@ -369,7 +369,6 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
cachedTokens: undefined,
},
totalTokens: undefined,
rawCompletionTokens: undefined,
}
let isFirstChunk = true
const providerOptionsName = this.providerOptionsName
@@ -433,11 +432,11 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
} = value.usage
usage.promptTokens = prompt_tokens ?? undefined
usage.rawCompletionTokens = completion_tokens ?? undefined
const output = outputUsage(value.usage)
usage.completionTokens = output.total
usage.completionTokensDetails.reasoningTokens = output.reasoning
usage.completionTokens = completion_tokens ?? undefined
usage.totalTokens = total_tokens ?? undefined
if (completion_tokens_details?.reasoning_tokens != null) {
usage.completionTokensDetails.reasoningTokens = completion_tokens_details?.reasoning_tokens
}
if (completion_tokens_details?.accepted_prediction_tokens != null) {
usage.completionTokensDetails.acceptedPredictionTokens =
completion_tokens_details?.accepted_prediction_tokens
@@ -709,7 +708,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
},
raw: {
prompt_tokens: usage.promptTokens ?? null,
completion_tokens: usage.rawCompletionTokens ?? null,
completion_tokens: usage.completionTokens ?? null,
total_tokens: usage.totalTokens ?? null,
},
},
@@ -728,7 +727,6 @@ const openaiCompatibleTokenUsageSchema = z
.object({
prompt_tokens: z.number().nullish(),
completion_tokens: z.number().nullish(),
reasoning_tokens: z.number().nullish(),
total_tokens: z.number().nullish(),
prompt_tokens_details: z
.object({
@@ -745,17 +743,6 @@ const openaiCompatibleTokenUsageSchema = z
})
.nullish()
function outputUsage(usage: z.infer<typeof openaiCompatibleTokenUsageSchema>) {
const nested = usage?.completion_tokens_details?.reasoning_tokens
return {
total:
usage?.completion_tokens == null
? undefined
: usage.completion_tokens + (nested == null ? (usage.reasoning_tokens ?? 0) : 0),
reasoning: nested ?? usage?.reasoning_tokens ?? undefined,
}
}
// limited version of the schema, focussed on what is needed for the implementation
// this approach limits breakages when the API changes and increases efficiency
const OpenAICompatibleChatResponseSchema = z.object({
+11 -2
View File
@@ -1,4 +1,4 @@
import { Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
import { Cause, Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ModelsDev } from "@opencode-ai/schema/models-dev"
import { Money } from "@opencode-ai/schema/money"
@@ -612,7 +612,16 @@ export const layer = (options?: Options) =>
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi()
const catalog = (yield* Schema.decodeUnknownEffect(CatalogJson)(text)) as Record<string, SourceProvider>
yield* kv.set(key, { updatedAt: Date.now(), body: text })
// Best-effort: a cache-write failure must never kill catalog
// population. The payload has outgrown some KV backends' per-value
// limits (Durable Object SQLite caps values at 2 MB and api.json
// passed it in Aug 2026); a boot without a cache hit just refetches.
yield* kv.set(key, { updatedAt: Date.now(), body: text }).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
),
)
return catalog
})
+19 -7
View File
@@ -3,7 +3,7 @@ export * as PluginHooks from "./hooks"
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
import type { ToolHooks } from "@opencode-ai/plugin/effect/tool"
import type { ToolFailures, ToolHooks } from "@opencode-ai/plugin/effect/tool"
import { Context, Effect, Layer, Scope } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { State } from "../state"
@@ -15,19 +15,29 @@ export interface Domains {
readonly tool: ToolHooks
}
type Callback<Event> = (event: Event) => Effect.Effect<void>
type NoFailures<Spec> = { readonly [Name in keyof Spec]: never }
// Failure channel for each hook event. Only tool execute.before may fail: a Tool.Error rejects the call before it runs.
interface Failures extends Record<keyof Domains, unknown> {
readonly aisdk: NoFailures<AISDKHooks>
readonly session: NoFailures<SessionHooks>
readonly shell: NoFailures<ShellHooks>
readonly tool: ToolFailures
}
type Callback<Event, Error> = (event: Event) => Effect.Effect<void, Error>
export interface Interface {
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
domain: Domain,
name: Name,
callback: Callback<Domains[Domain][Name]>,
callback: Callback<Domains[Domain][Name], Failures[Domain][Name]>,
) => Effect.Effect<State.Registration, never, Scope.Scope>
readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
domain: Domain,
name: Name,
event: Domains[Domain][Name],
) => Effect.Effect<Domains[Domain][Name]>
) => Effect.Effect<Domains[Domain][Name], Failures[Domain][Name]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginHooks") {}
@@ -56,7 +66,9 @@ const layer = Layer.effect(
const trigger: Interface["trigger"] = Effect.fn("PluginHooks.trigger")(function* (domain, name, event) {
for (const callback of callbacks.get(key(domain, name)) ?? []) {
const result: Effect.Effect<void> = Reflect.apply(callback, undefined, [event])
const result: Effect.Effect<void, Failures[typeof domain][typeof name]> = Reflect.apply(callback, undefined, [
event,
])
yield* result
}
return event
@@ -65,15 +65,15 @@ const userAttachmentContent = (files: readonly FileAttachment[]) => {
)
if (eligible.length < 2) return files.flatMap(attachmentContent)
const seen = new Map<string, string[]>()
const seen = new Map<string, Set<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 matches = seen.get(metadata)
if (matches?.includes(file.data)) return []
if (matches) matches.push(file.data)
if (!matches) seen.set(metadata, [file.data])
const payloads = seen.get(metadata) ?? new Set<string>()
if (payloads.has(file.data)) return []
payloads.add(file.data)
seen.set(metadata, payloads)
return attachmentContent(file)
})
}
+67
View File
@@ -275,6 +275,73 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
}),
)
it.effect("preserves tool result content in AI SDK prompts", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const resolved = yield* aisdk.model(model("test-ai-sdk"))
const prepared = yield* compileRequest(
LLM.request({
model: resolved,
messages: [
Message.tool({
id: "call_1",
name: "read",
result: {
type: "content",
value: [
{ type: "text", text: "attachments" },
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "pixel.png" },
{
type: "file",
uri: "data:application/pdf;charset=utf-8;base64,JVBERg==",
mime: "application/pdf",
name: "document.pdf",
},
{ type: "file", uri: "data:audio/mpeg;base64,SUQz", mime: "audio/mpeg", name: "clip.mp3" },
{ type: "file", uri: "https://example.com/pixel.png", mime: "image/png" },
{ type: "file", uri: "https://example.com/document.pdf", mime: "application/pdf" },
],
},
}),
],
}),
)
expect(prepared.body.prompt).toEqual([
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call_1",
toolName: "read",
output: {
type: "content",
value: [
{ type: "text", text: "attachments" },
{ type: "image-data", data: "AAAA", mediaType: "image/png" },
{
type: "file-data",
data: "JVBERg==",
mediaType: "application/pdf",
filename: "document.pdf",
},
{ type: "file-data", data: "SUQz", mediaType: "audio/mpeg", filename: "clip.mp3" },
{ type: "image-url", url: "https://example.com/pixel.png" },
{ type: "file-url", url: "https://example.com/document.pdf" },
],
},
},
],
},
])
}),
)
it.effect("emits malformed AI SDK tool input without executing it", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
@@ -204,7 +204,7 @@ describe("doStream", () => {
finishReason: { unified: "tool-calls" },
usage: {
inputTokens: { total: 19581 },
outputTokens: { total: 187, reasoning: 134 },
outputTokens: { total: 53 },
},
})
})
@@ -259,7 +259,7 @@ describe("doStream", () => {
finishReason: { unified: "stop" },
usage: {
inputTokens: { total: 5778 },
outputTokens: { total: 154, reasoning: 95 },
outputTokens: { total: 59 },
},
providerMetadata: {
copilot: {
@@ -391,7 +391,7 @@ describe("doStream", () => {
finishReason: { unified: "tool-calls" },
usage: {
inputTokens: { total: 3767 },
outputTokens: { total: 30, reasoning: 11 },
outputTokens: { total: 19 },
},
})
})
+28
View File
@@ -185,6 +185,15 @@ const buildLayer = (state: Ref.Ref<MockState>, cache: MockCache, options: Models
]),
)
// Mirrors production KV backends whose writes die as defects (e.g. Durable
// Object SQLite rejecting values over its 2 MB cap with EffectDrizzleQueryError).
const makeFailingWriteKV = (cache: MockCache) =>
Layer.mock(KV.Service, {
get: (key) => Effect.sync(() => cache.values.get(key)),
set: () => Effect.die(new Error('Failed query: insert into "kv"')),
remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
})
const makeCache = (): MockCache => ({ values: new Map() })
const writeCacheText = (cache: MockCache, text: string, updatedAt = Date.now()) =>
@@ -248,6 +257,25 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() still populates the catalog when the KV cache write fails", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const layer = Layer.fresh(
AppNodeBuilder.build(ModelsDev.node, [
[ModelsDev.node, ModelsDev.configured({ fetch: true })],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
[KV.node, makeFailingWriteKV(cache)],
]),
)
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(layer))
expect(result).toEqual(fixture2Snapshot)
expect(cache.values.has(cacheKey)).toBe(false)
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
}),
)
it.live("uses the default models URL when the configured URL is empty", () =>
Effect.gen(function* () {
const cache = makeCache()
+48
View File
@@ -1,4 +1,5 @@
import { describe, expect } from "bun:test"
import { ToolFailure } from "@opencode-ai/ai"
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
@@ -395,4 +396,51 @@ describe("Plugin", () => {
})
}),
)
it.effect("rejects tool execution when an execute.before hook fails", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const executed: unknown[] = []
const plugin = EffectPlugin.define({
id: "tool-hook-reject",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.tool
.transform((draft) =>
draft.add({
name: "echo",
options: { codemode: false },
description: "Echo",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) =>
Effect.sync(() => executed.push({ text })).pipe(Effect.as({ output: { text } })),
}),
)
.pipe(Effect.orDie)
yield* ctx.tool
.hook("execute.before", () => new ToolFailure({ message: "write disabled" }))
.pipe(Effect.asVoid)
}),
})
yield* plugins.activate([versioned(plugin)])
const toolSet = yield* registry.snapshot()
const failure = yield* toolSet
.execute({
sessionID: Session.ID.make("ses_hook_reject"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_hook_reject"),
call: { type: "tool-call", id: "call-hook-reject", name: "echo", input: { text: "original" } },
})
.pipe(Effect.flip)
expect(failure).toMatchObject({ _tag: "Tool.Error", message: "write disabled" })
expect(executed).toEqual([])
}),
)
})
@@ -57,35 +57,35 @@ test("keeps a hidden prod launcher for old Linux pins", async () => {
expect(desktop).toContain("NoDisplay=true")
})
test("bundles the CLI outside the dev app archive", async () => {
const previous = process.env.OPENCODE_CHANNEL
process.env.OPENCODE_CHANNEL = "dev"
const module = await import("./electron-builder.config.ts?cli-resource")
const config = module.default as Configuration
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
else process.env.OPENCODE_CHANNEL = previous
expect(config.files).toContain("!resources/opencode-cli*")
expect(config.extraResources).toContainEqual({
from: "resources/",
to: "",
filter: ["opencode-cli*"],
})
})
for (const channel of ["beta", "prod"] as const) {
test(`does not bundle the CLI in ${channel} builds`, async () => {
for (const channel of ["dev", "beta"] as const) {
test(`bundles the CLI outside the ${channel} app archive`, async () => {
const previous = process.env.OPENCODE_CHANNEL
process.env.OPENCODE_CHANNEL = channel
const module = await import(`./electron-builder.config.ts?no-cli-resource=${channel}`)
const module = await import(`./electron-builder.config.ts?cli-resource=${channel}`)
const config = module.default as Configuration
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
else process.env.OPENCODE_CHANNEL = previous
expect(config.extraResources).not.toContainEqual({
expect(config.files).toContain("!resources/opencode-cli*")
expect(config.extraResources).toContainEqual({
from: "resources/",
to: "",
filter: ["opencode-cli*"],
})
})
}
test("does not bundle the CLI in prod builds", async () => {
const previous = process.env.OPENCODE_CHANNEL
process.env.OPENCODE_CHANNEL = "prod"
const module = await import("./electron-builder.config.ts?no-cli-resource=prod")
const config = module.default as Configuration
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
else process.env.OPENCODE_CHANNEL = previous
expect(config.extraResources).not.toContainEqual({
from: "resources/",
to: "",
filter: ["opencode-cli*"],
})
})
+1 -1
View File
@@ -57,7 +57,7 @@ const getBase = (appId: string): Configuration => ({
},
files: ["out/**/*", "resources/**/*", "!resources/opencode-cli*"],
extraResources: [
...(channel === "dev"
...(channel !== "prod"
? [
{
from: "resources/",
+1
View File
@@ -37,6 +37,7 @@
"@actions/artifact": "4.0.0",
"@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:",
+2 -2
View File
@@ -1,9 +1,9 @@
import { $ } from "bun"
import * as path from "node:path"
import { RUST_TARGET } from "./utils"
import { CLI_TARGET } from "./utils"
if (!RUST_TARGET) throw new Error("RUST_TARGET not defined")
if (!CLI_TARGET) throw new Error("OPENCODE_CLI_TARGET not defined")
const BUNDLE_DIR = "dist"
const BUNDLES_OUT_DIR = path.join(process.cwd(), "dist/bundles")
+1
View File
@@ -8,3 +8,4 @@ await $`bun ./scripts/copy-icons.ts ${channel}`
await $`bun ./scripts/copy-metainfo.ts ${channel}`
if (channel === "dev") await downloadCliToResources()
if (channel === "beta") await downloadCliToResources("next")
+13 -13
View File
@@ -13,46 +13,46 @@ export function resolveChannel(): Channel {
return "dev"
}
export const CLI_BINARIES: Array<{ rustTarget: string; package: string; os: string; cpu: string }> = [
export const CLI_BINARIES: Array<{ target: string; package: string; os: string; cpu: string }> = [
{
rustTarget: "aarch64-apple-darwin",
target: "aarch64-apple-darwin",
package: "@opencode-ai/cli-darwin-arm64",
os: "darwin",
cpu: "arm64",
},
{
rustTarget: "x86_64-apple-darwin",
target: "x86_64-apple-darwin",
package: "@opencode-ai/cli-darwin-x64-baseline",
os: "darwin",
cpu: "x64",
},
{
rustTarget: "aarch64-pc-windows-msvc",
target: "aarch64-pc-windows-msvc",
package: "@opencode-ai/cli-windows-arm64",
os: "win32",
cpu: "arm64",
},
{
rustTarget: "x86_64-pc-windows-msvc",
target: "x86_64-pc-windows-msvc",
package: "@opencode-ai/cli-windows-x64-baseline",
os: "win32",
cpu: "x64",
},
{
rustTarget: "x86_64-unknown-linux-gnu",
target: "x86_64-unknown-linux-gnu",
package: "@opencode-ai/cli-linux-x64-baseline",
os: "linux",
cpu: "x64",
},
{
rustTarget: "aarch64-unknown-linux-gnu",
target: "aarch64-unknown-linux-gnu",
package: "@opencode-ai/cli-linux-arm64",
os: "linux",
cpu: "arm64",
},
]
export const RUST_TARGET = Bun.env.RUST_TARGET
export const CLI_TARGET = Bun.env.OPENCODE_CLI_TARGET
function nativeTarget() {
const { platform, arch } = process
@@ -62,19 +62,19 @@ function nativeTarget() {
throw new Error(`Unsupported platform: ${platform}/${arch}`)
}
export function getCurrentCli(target = RUST_TARGET ?? nativeTarget()) {
const binaryConfig = CLI_BINARIES.find((item) => item.rustTarget === target)
export function getCurrentCli(target = CLI_TARGET ?? nativeTarget()) {
const binaryConfig = CLI_BINARIES.find((item) => item.target === target)
if (!binaryConfig) throw new Error(`CLI configuration not available for target '${target}'`)
return binaryConfig
}
export async function downloadCliToResources() {
export async function downloadCliToResources(version = CLI_VERSION) {
const cli = getCurrentCli()
const directory = await mkdtemp(join(tmpdir(), "opencode-cli-"))
const dest = windowsify("resources/opencode-cli")
try {
await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${CLI_VERSION}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}`
await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${version}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}`
await copyFile(
join(directory, "node_modules", cli.package, "bin", cli.os === "win32" ? "opencode2.exe" : "opencode2"),
dest,
@@ -88,7 +88,7 @@ export async function downloadCliToResources() {
}
if (process.platform === "darwin") await $`codesign --force --sign - ${dest}`
console.log(`Copied ${cli.package} to ${dest}`)
console.log(`Copied ${cli.package}@${version} to ${dest}`)
}
export function windowsify(path: string) {
+23 -48
View File
@@ -1,3 +1,4 @@
import { Service } from "@opencode-ai/client/service"
import { execFile } from "node:child_process"
import { existsSync } from "node:fs"
import { chmod, copyFile, mkdir, rename, rm } from "node:fs/promises"
@@ -8,52 +9,34 @@ import { app } from "electron"
const execFileAsync = promisify(execFile)
const root = dirname(fileURLToPath(import.meta.url))
const stateHome = process.env.XDG_STATE_HOME
const desktopStateNames = ["ai.opencode.desktop.dev", "ai.opencode.desktop.beta", "ai.opencode.desktop"]
type Logger = {
log(message: string, meta?: Record<string, unknown>): void
error(message: string, meta?: Record<string, unknown>): void
}
export async function startBackgroundCli(logger: Logger, shellStateHome?: string) {
export async function startBackgroundCli(logger: Logger) {
const bundled = app.isPackaged
? join(process.resourcesPath, executableName())
: join(root, "../../resources", executableName())
logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
const version = await run(bundled, ["--version"], logger)
const version = parseVersion(await run(bundled, ["--version"], logger))
const binary = app.isPackaged ? await installCli(bundled, version, logger) : bundled
const candidates = [
...new Set([stateHome, shellStateHome, ...desktopStateNames.map((name) => join(app.getPath("appData"), name))]),
].filter((candidate) => candidate === undefined || existsSync(candidate))
const discovered = await Promise.all(
candidates.map(async (candidate) => ({
stateHome: candidate,
url: serviceUrl(await run(binary, ["service", "status"], logger, { stateHome: candidate })),
})),
)
const found = discovered.find((candidate) => candidate.url !== undefined)
logger.log("v2 CLI background instance checked", {
detected: Boolean(found),
...endpoint(found?.url),
})
const daemonStateHome = found?.stateHome ?? stateHome
const url = await run(binary, ["service", "start"], logger, { stateHome: daemonStateHome })
const password = await run(binary, ["service", "get", "password"], logger, {
redact: true,
stateHome: daemonStateHome,
const service = await Service.ensure({
version,
command: [binary, "serve", "--service"],
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
})
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
logger.log("v2 CLI background service ready", {
existing: Boolean(found),
username: "opencode",
...endpoint(url),
username: service.auth.username,
version,
...endpoint(service.url),
})
return {
url,
username: "opencode",
password,
url: service.url,
username: service.auth.username,
password: service.auth.password,
}
}
@@ -77,21 +60,13 @@ async function installCli(source: string, version: string, logger: Logger) {
return destination
}
async function run(
binary: string,
args: string[],
logger: Logger,
options: { redact?: boolean; stateHome?: string } = {},
) {
async function run(binary: string, args: string[], logger: Logger) {
logger.log("v2 CLI command started", { binary, args })
const env = { ...process.env }
if (options.stateHome === undefined) delete env.XDG_STATE_HOME
else env.XDG_STATE_HOME = options.stateHome
return execFileAsync(binary, args, { env, windowsHide: true }).then(
return execFileAsync(binary, args, { windowsHide: true }).then(
(result) => {
const stdout = result.stdout.trim()
const stderr = result.stderr.trim()
logger.log("v2 CLI command completed", { args, stdout: options.redact ? "[redacted]" : stdout, stderr })
logger.log("v2 CLI command completed", { args, stdout, stderr })
return stdout
},
(error: unknown) => {
@@ -99,7 +74,7 @@ async function run(
logger.error("v2 CLI command failed", {
args,
error: error instanceof Error ? error.message : String(error),
stdout: options.redact && output.stdout ? "[redacted]" : (output.stdout?.trim() ?? ""),
stdout: output.stdout?.trim() ?? "",
stderr: output.stderr?.trim() ?? "",
})
throw error
@@ -107,11 +82,11 @@ async function run(
)
}
function serviceUrl(status: string) {
if (URL.canParse(status)) return status
if (!status.startsWith("running ")) return
const url = status.slice("running ".length).trim()
return URL.canParse(url) ? url : undefined
function parseVersion(output: string) {
const marker = output.lastIndexOf(" v")
const version = marker === -1 ? output : output.slice(marker + 2)
if (!version) throw new Error("V2 CLI did not provide a version")
return version
}
function endpoint(url: string | undefined) {
+2 -2
View File
@@ -181,7 +181,7 @@ const main = Effect.gen(function* () {
return
}
const shellEnv = preferAppEnv(app.getPath("userData"))
preferAppEnv()
app.on("second-instance", (_event: Event, argv: string[]) => {
const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
@@ -310,7 +310,7 @@ const main = Effect.gen(function* () {
useEnvProxy()
logger.log("starting v2 background service")
const sidecar = yield* Effect.promise(() => startBackgroundCli(logger, shellEnv?.XDG_STATE_HOME))
const sidecar = yield* Effect.promise(() => startBackgroundCli(logger))
yield* Deferred.succeed(serverReady, {
url: sidecar.url,
username: sidecar.username,
+2 -3
View File
@@ -17,17 +17,16 @@ export function setDefaultServerUrl(url: string | null) {
getStore().delete(DEFAULT_SERVER_URL_KEY)
}
export function preferAppEnv(userDataPath: string) {
export function preferAppEnv() {
const shell = process.platform === "win32" ? null : getUserShell()
const shellEnv = shell ? loadShellEnv(shell, getLogger()) : null
if (!shellEnv?.XDG_STATE_HOME) delete process.env.XDG_STATE_HOME
Object.assign(process.env, {
...shellEnv,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop",
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
})
return shellEnv
}
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
+8 -7
View File
@@ -77,20 +77,21 @@ export function spatialPathSpans(points: readonly DiagramPoint[]): SpatialSpan[]
.sort(([left], [right]) => left - right)
.flatMap(([y, xs]) => {
const sorted = [...xs].sort((left, right) => left - right)
const [first, ...rest] = sorted
if (first === undefined) return []
const spans: SpatialSpan[] = []
let start = sorted[0]
let end = start
if (start === undefined) return spans
for (const x of sorted.slice(1)) {
if (x === end! + 1) {
let start = first
let end = first
for (const x of rest) {
if (x === end + 1) {
end = x
continue
}
spans.push(normalizedSpan(y, start, end!))
spans.push(normalizedSpan(y, start, end))
start = x
end = x
}
spans.push(normalizedSpan(y, start, end!))
spans.push(normalizedSpan(y, start, end))
return spans
})
}
+4 -2
View File
@@ -4,9 +4,11 @@ export interface Registration {
readonly dispose: Effect.Effect<void>
}
export type Hooks<Spec> = <Name extends keyof Spec>(
export type Hooks<Spec, Failures extends Record<keyof Spec, unknown> = Record<keyof Spec, never>> = <
Name extends keyof Spec,
>(
name: Name,
callback: (input: Spec[Name]) => Effect.Effect<void>,
callback: (input: Spec[Name]) => Effect.Effect<void, Failures[Name]>,
) => Effect.Effect<Registration, never, Scope.Scope>
export type Transform<Input> = (callback: (input: Input) => void) => Effect.Effect<Registration, never, Scope.Scope>
+7 -1
View File
@@ -38,7 +38,13 @@ export interface ToolHooks {
)
}
// Only execute.before may fail: a Tool.Error rejects the call before the tool runs.
export interface ToolFailures extends Record<keyof ToolHooks, unknown> {
readonly "execute.before": Tool.Error
readonly "execute.after": never
}
export interface ToolDomain {
readonly transform: Transform<ToolDraft>
readonly hook: Hooks<ToolHooks>
readonly hook: Hooks<ToolHooks, ToolFailures>
}
+1 -5
View File
@@ -62,13 +62,9 @@ function createMarquee(hovered: () => string | undefined, animations: () => bool
const leading = createAnimatable({ opacity: 0 }, { enabled: animations, transition: tween({ duration: 0.25 }) })
createEffect(() => {
if (!hovered()) {
setOffset(0)
leading.jump({ opacity: 0 })
return
}
setOffset(0)
leading.jump({ opacity: 0 })
if (!hovered()) return
let interval: ReturnType<typeof setInterval> | undefined
const delay = setTimeout(() => {
setOffset(1)
+6 -6
View File
@@ -25,14 +25,14 @@ function deduplicateByIdentity<T>(
items: readonly T[],
identity: (item: T) => { metadata: string; payload: string } | undefined,
) {
const seen = new Map<string, string[]>()
const seen = new Map<string, Set<string>>()
return items.filter((item) => {
const key = identity(item)
if (!key) return true
const matches = seen.get(key.metadata)
if (matches?.includes(key.payload)) return false
if (matches) matches.push(key.payload)
if (!matches) seen.set(key.metadata, [key.payload])
const payloads = seen.get(key.metadata) ?? new Set<string>()
if (payloads.has(key.payload)) return false
payloads.add(key.payload)
seen.set(key.metadata, payloads)
return true
})
}
@@ -42,7 +42,7 @@ export function deduplicatePromptImages(files: readonly PromptFile[] | undefined
return deduplicateByIdentity(files, (file) =>
file.uri.startsWith("data:image/") && file.mention?.text
? {
metadata: JSON.stringify([attachmentMetadata(file), file.mention.text]),
metadata: JSON.stringify([file.name ?? null, file.description ?? null, file.mention.text]),
payload: file.uri,
}
: undefined,
+12 -2
View File
@@ -650,8 +650,18 @@ export function Session() {
slash: {
name: "compact",
},
run: () => {
void client.api.session.compact({ sessionID: route.sessionID })
run: async () => {
const selection = local.model.current()
if (selection)
await client.api.session.switchModel({
sessionID: route.sessionID,
model: {
providerID: selection.providerID,
id: selection.modelID,
variant: local.model.variant.current(),
},
})
await client.api.session.compact({ sessionID: route.sessionID })
dialog.clear()
},
},
+4 -2
View File
@@ -7,13 +7,15 @@ import Config from "@npmcli/config"
import { definitions, flatten, nerfDarts, shorthands } from "@npmcli/config/lib/definitions/index.js"
import { Effect } from "effect"
const npmPath = fileURLToPath(new URL("..", import.meta.url))
// Lazy: on workerd import.meta.url is undefined and constructing a URL from it
// at module scope fails startup validation; npm config is never used there.
const npmPath = () => fileURLToPath(new URL("..", import.meta.url))
export const load = (dir: string) =>
Effect.tryPromise({
try: async () => {
const config = new Config({
npmPath,
npmPath: npmPath(),
cwd: dir,
env: { ...process.env },
argv: [process.execPath, process.execPath, "--prefix", dir],
+8 -2
View File
@@ -1,6 +1,6 @@
export * as Observability from "./observability.js"
import { NodeFileSystem } from "@effect/platform-node"
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
import { LayerNode } from "./effect/layer-node.js"
import { Effect, Layer, Logger, References, Schema } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
@@ -50,4 +50,10 @@ export function layer(
).pipe(Layer.catchCause(() => local))
}
export const node = LayerNode.make({ name: "observability", layer: layer(), deps: [] })
// Layer.suspend: constructing the loggers eagerly at module scope performs
// I/O (file logger, run id) that workerd forbids in global scope.
export const node = LayerNode.make({
name: "observability",
layer: Layer.suspend(() => layer()),
deps: [],
})
+2 -2
View File
@@ -3,7 +3,7 @@ import path from "path"
import { Global } from "../global.js"
import { runID } from "./shared.js"
function formatter(id: string = runID) {
function formatter(id: string = runID()) {
return Logger.map(Logger.formatStructured, (output) => {
const messages = Array.isArray(output.message) ? output.message : [output.message]
return [
@@ -51,7 +51,7 @@ export function file(local = true, channel = "local") {
return path.join(Global.Path.log, `opencode-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.log`)
}
export function fileLogger(target = file(), id: string = runID) {
export function fileLogger(target = file(), id: string = runID()) {
// Do not set batchWindow to 0; it causes high idle CPU usage.
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
+2 -2
View File
@@ -54,8 +54,8 @@ export function resource(app: App = { client: "opencode", version: "unknown", ch
...resourceAttributes(),
"deployment.environment.name": app.channel,
"opencode.client": app.client,
"opencode.run": runID,
"service.instance.id": runID,
"opencode.run": runID(),
"service.instance.id": runID(),
},
}
}
+8 -1
View File
@@ -1 +1,8 @@
export const runID = crypto.randomUUID().slice(0, 8)
// Lazy: workerd forbids generating random values in global scope, so the id
// materializes on first call (inside a handler) and stays stable afterwards.
let generated: string | undefined
export function runID(): string {
generated ??= crypto.randomUUID().slice(0, 8)
return generated
}
+20 -18
View File
@@ -35,32 +35,34 @@ if (Script.release && !Script.preview) {
await prepareReleaseFiles()
console.log("\n=== schema ===\n")
await $`bun ./packages/schema/script/publish.ts`
if (Script.channel !== "beta") {
console.log("\n=== schema ===\n")
await $`bun ./packages/schema/script/publish.ts`
console.log("\n=== theme ===\n")
await $`bun ./packages/theme/script/publish.ts`
console.log("\n=== theme ===\n")
await $`bun ./packages/theme/script/publish.ts`
console.log("\n=== ai ===\n")
await $`bun ./packages/ai/script/publish.ts`
console.log("\n=== ai ===\n")
await $`bun ./packages/ai/script/publish.ts`
console.log("\n=== util ===\n")
await $`bun ./packages/util/script/publish.ts`
console.log("\n=== util ===\n")
await $`bun ./packages/util/script/publish.ts`
console.log("\n=== protocol ===\n")
await $`bun ./packages/protocol/script/publish.ts`
console.log("\n=== protocol ===\n")
await $`bun ./packages/protocol/script/publish.ts`
console.log("\n=== client ===\n")
await $`bun ./packages/client/script/publish.ts`
console.log("\n=== client ===\n")
await $`bun ./packages/client/script/publish.ts`
console.log("\n=== cli ===\n")
await $`bun ./packages/cli/script/publish.ts`
console.log("\n=== cli ===\n")
await $`bun ./packages/cli/script/publish.ts`
console.log("\n=== plugin ===\n")
await $`bun ./packages/plugin/script/publish.ts`
console.log("\n=== plugin ===\n")
await $`bun ./packages/plugin/script/publish.ts`
console.log("\n=== ui ===\n")
await $`bun ./packages/ui/script/publish.ts`
console.log("\n=== ui ===\n")
await $`bun ./packages/ui/script/publish.ts`
}
if (Script.release) {
await $`bun ./packages/desktop/scripts/finalize-latest-json.ts`